forked from eatvio/eatvio_app
Merge pull request 'BUILD 94' (#884) from main into test-flight
Reviewed-on: eatvio/eatvio_app#884
This commit is contained in:
@@ -3,7 +3,7 @@ name: Eatvio App Android Test
|
||||
on: [ pull_request ]
|
||||
|
||||
env:
|
||||
flutter_version: "3.32.0"
|
||||
flutter_version: "3.38.6"
|
||||
java_version: "21.x"
|
||||
flutter_channel: "stable"
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ name: Eatvio App Android Test
|
||||
on: [ pull_request ]
|
||||
|
||||
env:
|
||||
flutter_version: "3.38.5"
|
||||
flutter_version: "3.38.6"
|
||||
java_version: "21.x"
|
||||
flutter_channel: "stable"
|
||||
my_local_cache_dir: "/opt/hostedtoolcache/"
|
||||
|
||||
52
analyze_coverage_by_uncovered.py
Normal file
52
analyze_coverage_by_uncovered.py
Normal file
@@ -0,0 +1,52 @@
|
||||
#!/usr/bin/env python3
|
||||
import re
|
||||
|
||||
def analyze_coverage_by_uncovered():
|
||||
files = {}
|
||||
current_file = None
|
||||
|
||||
with open('coverage/lcov.info', 'r', encoding='utf-8') as f:
|
||||
lines = f.readlines()
|
||||
|
||||
i = 0
|
||||
while i < len(lines):
|
||||
line = lines[i].strip()
|
||||
if line.startswith('SF:'):
|
||||
current_file = line[3:]
|
||||
files[current_file] = {'total': 0, 'covered': 0}
|
||||
i += 1
|
||||
elif line.startswith('LF:') and current_file:
|
||||
files[current_file]['total'] = int(line[3:])
|
||||
i += 1
|
||||
elif line.startswith('LH:') and current_file:
|
||||
files[current_file]['covered'] = int(line[3:])
|
||||
i += 1
|
||||
else:
|
||||
i += 1
|
||||
|
||||
# Filter out generated files and UI screens/widgets (hard to test)
|
||||
filtered = []
|
||||
exclude_patterns = ['.g.dart', 'generated', 'l10n.dart', '/pages/', '/widgets/', '_screen.dart', '_widget.dart']
|
||||
|
||||
for f, d in files.items():
|
||||
if d['total'] > 0:
|
||||
# Skip UI files
|
||||
should_exclude = any(pattern in f for pattern in exclude_patterns)
|
||||
if not should_exclude:
|
||||
uncovered = d['total'] - d['covered']
|
||||
pct = d['covered']/d['total']*100
|
||||
filtered.append((f, d, uncovered, pct))
|
||||
|
||||
# Sort by uncovered lines (most uncovered first)
|
||||
sorted_files = sorted(filtered, key=lambda x: x[2], reverse=True)
|
||||
|
||||
print('Files sorted by uncovered lines (excluding UI screens/widgets):')
|
||||
print('=' * 100)
|
||||
print(f'{"Uncovered":>10} {"Covered":>8} {"Total":>8} {"%":>7} {"File":<60}')
|
||||
print('=' * 100)
|
||||
for f, d, uncovered, pct in sorted_files[:30]:
|
||||
print(f'{uncovered:>10} {d["covered"]:>8} {d["total"]:>8} {pct:>6.1f}% {f}')
|
||||
|
||||
if __name__ == '__main__':
|
||||
analyze_coverage_by_uncovered()
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
Files with less than 50% coverage:
|
||||
================================================================================
|
||||
0.0% ( 0/ 37) - lib/core/firebase_api.dart
|
||||
0.0% ( 0/ 1) - lib/features/auth/models/oauth_verification_confirm_request.dart
|
||||
0.0% ( 0/ 1) - lib/features/auth/models/oauth_verification_confirm_response.dart
|
||||
0.0% ( 0/ 1) - lib/features/auth/models/oauth_verification_request_response.dart
|
||||
0.0% ( 0/ 342) - lib/features/dev_tools/test_cases.dart
|
||||
0.9% ( 2/ 220) - lib/features/dev_tools/presentation/dev_tools_screen.dart
|
||||
2.4% ( 1/ 42) - lib/features/auth/presentation/pages/scan_qr_code_screen.dart
|
||||
2.9% ( 1/ 34) - lib/features/diary/presentation/pages/barcode_scanner_screen.dart
|
||||
3.6% ( 1/ 28) - lib/features/personal_diet_suggestion/presentation/personal_diet_suggestion_screen.dart
|
||||
3.8% ( 1/ 26) - lib/features/update_screen/presentation/update_screen.dart
|
||||
4.4% ( 4/ 90) - lib/core/widgets/custom_popup.dart
|
||||
5.9% ( 1/ 17) - lib/features/recipe/presentation/pages/my_recipes_screen.dart
|
||||
6.2% ( 2/ 32) - lib/features/consultations/presentation/pages/consultations_screen.dart
|
||||
7.1% ( 1/ 14) - lib/features/menu/presentation/pages/blocked_users_screen.dart
|
||||
7.7% ( 1/ 13) - lib/features/registration_v2/presentation/pages/registration_v2_page.dart
|
||||
8.3% ( 18/ 217) - lib/features/blog_post/presentation/pages/blog_post_screen.dart
|
||||
11.1% ( 1/ 9) - lib/features/registration_v2/presentation/widgets/step_progress.dart
|
||||
11.8% ( 14/ 119) - lib/features/auth/sdk/social_auth_facade.dart
|
||||
13.0% ( 3/ 23) - lib/features/registration/presentation/bloc/registration_cubit/registration_cubit.dart
|
||||
14.5% ( 11/ 76) - lib/core/app_router.dart
|
||||
19.3% ( 11/ 57) - lib/features/create_recipe/presentation/widgets/image_widget.dart
|
||||
19.6% ( 10/ 51) - lib/features/create_recipe/entities/recipe_create_entity.dart
|
||||
19.7% ( 12/ 61) - lib/features/create_recipe/presentation/widgets/step_image_widget.dart
|
||||
20.0% ( 6/ 30) - lib/core/image_compressing/image_compress_utility.dart
|
||||
20.0% ( 57/ 285) - lib/features/complaining/presentation/complaining_bottom_sheet.dart
|
||||
21.9% ( 7/ 32) - lib/core/routing/content_routes.dart
|
||||
25.6% ( 46/ 180) - lib/features/chat/presentation/bloc/chat_room_cubit/chat_room_cubit.dart
|
||||
26.8% ( 11/ 41) - lib/features/create_recipe/reporitories/recipe_repository.dart
|
||||
27.4% ( 96/ 351) - lib/features/blog_post_create/presentation/pages/blog_post_create_screen.dart
|
||||
28.0% ( 28/ 100) - lib/core/utils/image_constant.dart
|
||||
28.4% ( 23/ 81) - lib/core/websockets/ws_manager.dart
|
||||
29.0% ( 157/ 541) - lib/features/profile/presentation/pages/profile_screen.dart
|
||||
29.3% ( 137/ 468) - lib/features/notifications/models/notification_model.dart
|
||||
33.1% ( 47/ 142) - lib/features/notifications/presentation/notifications_cubit/notifications_cubit.dart
|
||||
33.3% ( 9/ 27) - lib/features/bottom_navigation_bar/presentation/bloc/profile_avatar_cubit/profile_avatar_cubit.dart
|
||||
35.2% ( 58/ 165) - lib/features/registration/presentation/pages/registration_screen.dart
|
||||
35.6% ( 36/ 101) - lib/features/chat/presentation/pages/empty_chat_screen.dart
|
||||
36.0% ( 9/ 25) - lib/features/auth/presentation/bloc/auth_cubit/auth_cubit.dart
|
||||
36.7% ( 11/ 30) - lib/core/routing/main_navigation_routes.dart
|
||||
37.5% ( 3/ 8) - lib/core/permission_handler/permission_handler.dart
|
||||
38.6% ( 22/ 57) - lib/features/registration/presentation/pages/date_of_birth_screen.dart
|
||||
38.9% ( 35/ 90) - lib/core/routing/other_routes.dart
|
||||
39.0% ( 30/ 77) - lib/features/create_diet/presentation/widgets/diet_image_picker_widget.dart
|
||||
39.3% ( 119/ 303) - lib/features/settings/presentation/pages/settings_screen.dart
|
||||
39.3% ( 22/ 56) - lib/features/consultant/become_consultant/presentation/pages/photo_or_scans_screen.dart
|
||||
40.0% ( 4/ 10) - lib/features/menu/presentation/bloc/notification_settings_cubit/notification_settings_state.dart
|
||||
40.0% ( 4/ 10) - lib/features/registration/services/registration_analytics_service.dart
|
||||
41.5% ( 95/ 229) - lib/features/create_diet/presentation/cubit/create_diet_cubit.dart
|
||||
41.9% ( 31/ 74) - lib/features/create_plan/presentation/widgets/plan_image_picker_widget.dart
|
||||
42.7% ( 67/ 157) - lib/features/create_recipe/presentation/bloc/create_recipe_cubit/create_recipe_cubit.dart
|
||||
|
||||
@@ -4,6 +4,7 @@ plugins {
|
||||
id 'com.google.gms.google-services'
|
||||
// END: FlutterFire Configuration
|
||||
id "kotlin-android"
|
||||
id "kotlin-parcelize"
|
||||
id "dev.flutter.flutter-gradle-plugin"
|
||||
id 'com.huawei.agconnect'
|
||||
}
|
||||
@@ -68,10 +69,10 @@ android {
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation 'com.huawei.agconnect:agconnect-core:1.9.1.301'
|
||||
implementation 'com.huawei.hms:push:6.12.0.300'
|
||||
implementation 'com.huawei.agconnect:agconnect-core:1.9.5.300'
|
||||
implementation 'com.huawei.hms:push:6.13.0.301'
|
||||
// Huawei Analytics dependency to satisfy com.huawei.hianalytics.* references during R8
|
||||
implementation 'com.huawei.hms:hianalytics:6.12.0.300'
|
||||
implementation 'com.huawei.hms:hianalytics:6.12.0.301'
|
||||
// BouncyCastle provider required by Huawei secure utils used transitively by HMS
|
||||
implementation 'org.bouncycastle:bcprov-jdk15to18:1.78.1'
|
||||
}
|
||||
|
||||
@@ -33,15 +33,16 @@ class AppConfig {
|
||||
}
|
||||
|
||||
/// Список глобальных BlocProvider'ов
|
||||
/// Используются lazy: true для отложенной инициализации, чтобы не блокировать старт приложения
|
||||
List<BlocProvider> get globalBlocProviders => [
|
||||
BlocProvider<NewMessagesCubit>(create: (context) => sl<NewMessagesCubit>()),
|
||||
BlocProvider<NotificationsCubit>(create: (context) => sl<NotificationsCubit>()..getNotifications()),
|
||||
BlocProvider<FeedCubit>(create: (context) => sl<FeedCubit>()..loadFeedBlogPostList()),
|
||||
BlocProvider<ProfileAvatarCubit>(create: (context) => sl<ProfileAvatarCubit>()..getMyProfile()),
|
||||
BlocProvider<ChatsCubit>(create: (context) => sl<ChatsCubit>()..getChatRoomsWithLoading()),
|
||||
BlocProvider<ViewsCubit>(create: (context) => sl<ViewsCubit>()),
|
||||
BlocProvider<NewMessagesCubit>(lazy: true, create: (context) => sl<NewMessagesCubit>()),
|
||||
BlocProvider<NotificationsCubit>(lazy: true, create: (context) => sl<NotificationsCubit>()),
|
||||
BlocProvider<FeedCubit>(lazy: true, create: (context) => sl<FeedCubit>()),
|
||||
BlocProvider<ProfileAvatarCubit>(lazy: true, create: (context) => sl<ProfileAvatarCubit>()),
|
||||
BlocProvider<ChatsCubit>(lazy: true, create: (context) => sl<ChatsCubit>()),
|
||||
BlocProvider<ViewsCubit>(lazy: true, create: (context) => sl<ViewsCubit>()),
|
||||
BlocProvider<InternetConnectionCubit>(lazy: false, create: (context) => sl<InternetConnectionCubit>()),
|
||||
BlocProvider<MarketingCubit>(create: (context) => sl<MarketingCubit>()),
|
||||
BlocProvider<MarketingCubit>(lazy: true, create: (context) => sl<MarketingCubit>()),
|
||||
];
|
||||
|
||||
/// Делегаты локализации
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import 'dart:async';
|
||||
import 'dart:developer';
|
||||
import 'dart:io' as io;
|
||||
import 'package:eatvio_app/core/app_config.dart';
|
||||
import 'package:eatvio_app/core/env_config.dart';
|
||||
@@ -37,9 +39,19 @@ class AppInitializer {
|
||||
|
||||
/// Инициализация Firebase
|
||||
static Future<void> _initializeFirebase() async {
|
||||
await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);
|
||||
await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform).timeout(
|
||||
const Duration(seconds: 10),
|
||||
onTimeout: () {
|
||||
throw TimeoutException('Firebase initialization timeout');
|
||||
},
|
||||
);
|
||||
if (sl<AppConfig>().buildType == AppBuildType.prod) {
|
||||
await FirebaseMessaging.instance.requestPermission();
|
||||
try {
|
||||
await FirebaseMessaging.instance.requestPermission().timeout(const Duration(seconds: 5));
|
||||
} catch (e) {
|
||||
// Разрешение не критично, продолжаем работу
|
||||
log('Failed to request Firebase messaging permission: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,6 +80,10 @@ class AppInitializer {
|
||||
options.tracesSampleRate = 1.0;
|
||||
options.debug = false;
|
||||
}, appRunner: appRunner);
|
||||
FlutterError.onError = (details) async {
|
||||
if (kDebugMode) return;
|
||||
await Sentry.captureException(details.exception, stackTrace: details.stack);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -79,7 +79,7 @@ class CubitRegistrations {
|
||||
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()));
|
||||
sl.registerFactory<VerifyEmailCubit>(() => VerifyEmailCubit(profileRepository: sl(), prefs: sl()));
|
||||
sl.registerFactory<OAuthCubit>(() => OAuthCubit(authRepository: sl(), prefs: sl()));
|
||||
sl.registerFactory<RegistrationCubitV2>(() => RegistrationCubitV2(registrationRepository: sl()));
|
||||
// Feed related cubits
|
||||
|
||||
@@ -20,13 +20,19 @@ class ExternalRegistrations {
|
||||
static void _registerDio() {
|
||||
sl.registerLazySingleton(
|
||||
() =>
|
||||
Dio(BaseOptions(baseUrl: sl<AppConfig>().baseUrl, headers: {'accept': 'application/json', 'content-type': 'application/json'}))
|
||||
Dio(BaseOptions(
|
||||
baseUrl: sl<AppConfig>().baseUrl,
|
||||
headers: {'accept': 'application/json', 'content-type': 'application/json'},
|
||||
connectTimeout: const Duration(seconds: 10),
|
||||
receiveTimeout: const Duration(seconds: 10),
|
||||
sendTimeout: const Duration(seconds: 10),
|
||||
))
|
||||
..interceptors.addAll([TokenInterceptor(), PhoneVerifyErrorInterceptor()]),
|
||||
);
|
||||
}
|
||||
|
||||
/// Регистрация Prefs
|
||||
static void _registerPrefs() {
|
||||
sl.registerSingletonAsync<Prefs>(()async => Prefs(await SharedPreferences.getInstance()));
|
||||
sl.registerSingletonAsync<Prefs>(() async => Prefs(await SharedPreferences.getInstance()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'dart:async';
|
||||
import 'dart:developer';
|
||||
import 'dart:io';
|
||||
import 'package:eatvio_app/core/app_config.dart';
|
||||
@@ -19,12 +20,25 @@ class FirebaseApi {
|
||||
String? token;
|
||||
|
||||
if (await _isHuaweiDevice()) {
|
||||
token = await _getHuaweiPushTokenNative();
|
||||
token = await _getHuaweiPushTokenNative().timeout(
|
||||
const Duration(seconds: 5),
|
||||
onTimeout: () => null,
|
||||
);
|
||||
} else {
|
||||
if ((sl<AppConfig>().buildType == AppBuildType.prod)) {
|
||||
await _firebaseMessaging.requestPermission();
|
||||
try {
|
||||
await _firebaseMessaging.requestPermission().timeout(
|
||||
const Duration(seconds: 5),
|
||||
);
|
||||
} catch (e) {
|
||||
// Разрешение не критично, продолжаем
|
||||
log('Failed to request Firebase messaging permission: $e');
|
||||
}
|
||||
}
|
||||
token = await _firebaseMessaging.getToken();
|
||||
token = await _firebaseMessaging.getToken().timeout(
|
||||
const Duration(seconds: 5),
|
||||
onTimeout: () => null,
|
||||
);
|
||||
}
|
||||
return token;
|
||||
}
|
||||
@@ -39,9 +53,15 @@ class FirebaseApi {
|
||||
'hw_id': Platform.isIOS ? (await deviceInfo.iosInfo).identifierForVendor : (await deviceInfo.androidInfo).id,
|
||||
'token': await getToken(),
|
||||
},
|
||||
).timeout(
|
||||
const Duration(seconds: 10),
|
||||
onTimeout: () {
|
||||
throw TimeoutException('registerAnonymousPushToken timeout');
|
||||
},
|
||||
);
|
||||
} catch (e, st) {
|
||||
log(e.toString(), stackTrace: st);
|
||||
// Не пробрасываем ошибку дальше, так как это не критичная операция
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,9 +77,15 @@ class FirebaseApi {
|
||||
'hw_id': Platform.isIOS ? (await deviceInfo.iosInfo).identifierForVendor : (await deviceInfo.androidInfo).id,
|
||||
'token': await getToken(),
|
||||
},
|
||||
).timeout(
|
||||
const Duration(seconds: 10),
|
||||
onTimeout: () {
|
||||
throw TimeoutException('registerPushToken timeout');
|
||||
},
|
||||
);
|
||||
} catch (e, st) {
|
||||
log(e.toString(), stackTrace: st);
|
||||
// Не пробрасываем ошибку дальше, так как это не критичная операция
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,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/prefs.dart';
|
||||
import 'package:eatvio_app/core/websockets/ws_manager.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
|
||||
@@ -18,6 +19,9 @@ Future<void> init({required AppBuildType buildType}) async {
|
||||
// Регистрация внешних зависимостей (Dio, etc.)
|
||||
ExternalRegistrations.registerAll();
|
||||
|
||||
// Ожидание инициализации асинхронных зависимостей
|
||||
await sl.getAsync<Prefs>();
|
||||
|
||||
// Регистрация сервисов
|
||||
ServiceRegistrations.registerAll();
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
enum PrefsKeys { hasAnonymousTokenBeenSent, welcomeScreenWasShowed, registrationDate, marketingBottomSheetHasBeenShown }
|
||||
enum PrefsKeys { hasAnonymousTokenBeenSent, welcomeScreenWasShowed, registrationDate, marketingBottomSheetHasBeenShown, isEmailVerificationSkipped }
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import 'package:eatvio_app/core/firebase_api.dart';
|
||||
import 'package:eatvio_app/core/locator_service.dart';
|
||||
import 'package:eatvio_app/core/routing/route_constants.dart';
|
||||
import 'package:eatvio_app/features/auth/entities/auth_result_entity.dart';
|
||||
import 'package:eatvio_app/features/auth/presentation/pages/auth_screen.dart';
|
||||
@@ -21,10 +19,8 @@ class AuthRoutes {
|
||||
path: RouteConstants.authScreen,
|
||||
name: 'authScreen',
|
||||
builder: (context, state) => const AuthScreen(),
|
||||
redirect: (context, state) {
|
||||
sl<FirebaseApi>().registerAnonymousPushToken();
|
||||
return null;
|
||||
},
|
||||
// registerAnonymousPushToken вызывается в AuthCubit.initializeUser()
|
||||
// и не должен блокировать навигацию
|
||||
routes: [
|
||||
GoRoute(path: RouteConstants.registrationScreen, name: 'registrationScreen', builder: (context, state) => const RegistrationScreen()),
|
||||
GoRoute(path: RouteConstants.registrationScreenV2, name: 'registrationScreenV2', builder: (context, state) => const RegistrationV2Page()),
|
||||
|
||||
@@ -23,14 +23,16 @@ class MainNavigationRoutes {
|
||||
return [
|
||||
ShellRoute(
|
||||
builder: (context, state, child) {
|
||||
return BlocBuilder<ProfileAvatarCubit, ProfileAvatarState>(
|
||||
builder: (BuildContext context, ProfileAvatarState state) {
|
||||
if (state is ProfileAvatarStateLoaded) {
|
||||
return BottomNavigationBarWidget(profileModel: state.profile, child: child);
|
||||
}
|
||||
return const SizedBox.shrink();
|
||||
},
|
||||
);
|
||||
// Инициализация ProfileAvatarCubit при первом использовании ShellRoute
|
||||
final profileAvatarCubit = context.read<ProfileAvatarCubit>();
|
||||
if (profileAvatarCubit.state is ProfileAvatarStateLoading) {
|
||||
// Вызываем getMyProfile() только если состояние loading (первый запуск)
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
profileAvatarCubit.getMyProfile();
|
||||
});
|
||||
}
|
||||
|
||||
return BottomNavigationBarWidget(child: child);
|
||||
},
|
||||
routes: [
|
||||
GoRoute(
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import 'dart:async';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:eatvio_app/core/app_config.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/features/profile/repositories/profile_repository.dart';
|
||||
import 'package:eatvio_app/features/calendar/presentation/pages/calendar_screen.dart';
|
||||
import 'package:eatvio_app/features/chat/presentation/pages/chat_screen.dart';
|
||||
import 'package:eatvio_app/features/diary/presentation/pages/diary_screen.dart';
|
||||
@@ -56,10 +60,28 @@ class OtherRoutes {
|
||||
builder: (context, state) => const WelcomeScreen(),
|
||||
redirect: (context, state) async {
|
||||
try {
|
||||
final prefs = sl<Prefs>();
|
||||
final welcomeScreenWasShowed = prefs.getBool(PrefsKeys.welcomeScreenWasShowed) ?? false;
|
||||
// Prefs уже инициализирован в locator_service, но на всякий случай проверяем
|
||||
final prefs = await sl.getAsync<Prefs>();
|
||||
final welcomeScreenWasShowed = prefs.getBool(PrefsKeys.welcomeScreenWasShowed) ?? false;
|
||||
if (!welcomeScreenWasShowed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (await TokenStorage.haveTokenInStorage()) {
|
||||
try {
|
||||
final profileRepository = sl<ProfileRepository>();
|
||||
final profile = await profileRepository.getMyProfile();
|
||||
|
||||
final firebaseApi = sl<FirebaseApi>();
|
||||
await firebaseApi.registerPushToken();
|
||||
|
||||
if (profile.emailVerifiedAt != null || (prefs.getBool(PrefsKeys.isEmailVerificationSkipped) ?? false)) {
|
||||
return RouteConstants.feedScreen;
|
||||
}
|
||||
return RouteConstants.verifyEmailScreen;
|
||||
} catch (e) {
|
||||
return '/auth-screen';
|
||||
}
|
||||
} else {
|
||||
return '/auth-screen';
|
||||
}
|
||||
@@ -74,7 +96,27 @@ class OtherRoutes {
|
||||
builder: (context, state) => const UpdateScreen(),
|
||||
redirect: (context, state) async {
|
||||
try {
|
||||
final versions = (await sl<Dio>().get('${sl<AppConfig>().baseUrl}/api/app_versions')).data;
|
||||
// Проверка версии с таймаутом, чтобы не блокировать запуск приложения
|
||||
final versions = await sl<Dio>()
|
||||
.get('${sl<AppConfig>().baseUrl}/api/app_versions')
|
||||
.timeout(
|
||||
const Duration(seconds: 5),
|
||||
onTimeout: () {
|
||||
// При таймауте пропускаем проверку и идем дальше
|
||||
throw TimeoutException('Version check timeout');
|
||||
},
|
||||
)
|
||||
.then((response) => response.data)
|
||||
.catchError((e) {
|
||||
// При любой ошибке пропускаем проверку версии
|
||||
return null;
|
||||
});
|
||||
|
||||
if (versions == null) {
|
||||
// Если проверка не удалась, пропускаем экран обновления
|
||||
return RouteConstants.welcomeScreen;
|
||||
}
|
||||
|
||||
PackageInfo packageInfo = await PackageInfo.fromPlatform();
|
||||
String version = packageInfo.version;
|
||||
Version currentVersion = Version.parse(version);
|
||||
@@ -85,6 +127,7 @@ class OtherRoutes {
|
||||
return RouteConstants.welcomeScreen;
|
||||
}
|
||||
} catch (e) {
|
||||
// При любой ошибке пропускаем экран обновления
|
||||
return RouteConstants.welcomeScreen;
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1,9 +1,24 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
abstract class TokenStorage {
|
||||
static SharedPreferences? _cachedPrefs;
|
||||
|
||||
static Future<SharedPreferences> _getPrefs() async {
|
||||
_cachedPrefs ??= await SharedPreferences.getInstance();
|
||||
return _cachedPrefs!;
|
||||
}
|
||||
|
||||
/// Clears the cached SharedPreferences instance.
|
||||
/// This is primarily used for testing to ensure fresh state between tests.
|
||||
@visibleForTesting
|
||||
static void clearCache() {
|
||||
_cachedPrefs = null;
|
||||
}
|
||||
|
||||
static Future<bool> haveTokenInStorage() async {
|
||||
final sharedPreferences = await SharedPreferences.getInstance();
|
||||
try {
|
||||
final sharedPreferences = await _getPrefs();
|
||||
if (sharedPreferences.getString('token') == null) {
|
||||
return false;
|
||||
} else {
|
||||
@@ -15,25 +30,25 @@ abstract class TokenStorage {
|
||||
}
|
||||
|
||||
static Future<String?> getTokenFromStorage() async {
|
||||
final sharedPreferences = await SharedPreferences.getInstance();
|
||||
final sharedPreferences = await _getPrefs();
|
||||
return sharedPreferences.getString('token');
|
||||
}
|
||||
|
||||
static Future<void> saveTokenInStorage(String token) async {
|
||||
final sharedPreferences = await SharedPreferences.getInstance();
|
||||
final sharedPreferences = await _getPrefs();
|
||||
await sharedPreferences.setString('token', token);
|
||||
}
|
||||
|
||||
static Future<void> removeTokenInStorage() async {
|
||||
final sharedPreferences = await SharedPreferences.getInstance();
|
||||
final sharedPreferences = await _getPrefs();
|
||||
await sharedPreferences.remove('token');
|
||||
}
|
||||
}
|
||||
|
||||
abstract class SocketIdStorage {
|
||||
static Future<bool> haveSocketIdInStorage() async {
|
||||
final sharedPreferences = await SharedPreferences.getInstance();
|
||||
try {
|
||||
final sharedPreferences = await TokenStorage._getPrefs();
|
||||
if (sharedPreferences.getString('socketId') == null) {
|
||||
return false;
|
||||
} else {
|
||||
@@ -45,17 +60,17 @@ abstract class SocketIdStorage {
|
||||
}
|
||||
|
||||
static Future<String?> getSocketIdFromStorage() async {
|
||||
final sharedPreferences = await SharedPreferences.getInstance();
|
||||
final sharedPreferences = await TokenStorage._getPrefs();
|
||||
return sharedPreferences.getString('socketId');
|
||||
}
|
||||
|
||||
static Future<void> saveSocketIdInStorage(String socketId) async {
|
||||
final sharedPreferences = await SharedPreferences.getInstance();
|
||||
final sharedPreferences = await TokenStorage._getPrefs();
|
||||
await sharedPreferences.setString('socketId', socketId);
|
||||
}
|
||||
|
||||
static Future<void> removeSocketIdInStorage() async {
|
||||
final sharedPreferences = await SharedPreferences.getInstance();
|
||||
final sharedPreferences = await TokenStorage._getPrefs();
|
||||
await sharedPreferences.remove('socketId');
|
||||
}
|
||||
}
|
||||
|
||||
67
lib/core/utils/markdown_utils.dart
Normal file
67
lib/core/utils/markdown_utils.dart
Normal file
@@ -0,0 +1,67 @@
|
||||
/// Утилиты для обработки markdown текста
|
||||
class MarkdownUtils {
|
||||
/// Обрабатывает markdown текст:
|
||||
/// - Исправляет неправильный синтаксис markdown ссылок
|
||||
/// - Автоматически преобразует URL в markdown ссылки
|
||||
/// - Преобразует **текст** в <b>текст</b> для надежного отображения жирного текста
|
||||
static String processMarkdown(String text) {
|
||||
// Сначала исправляем неправильный синтаксис markdown ссылок
|
||||
// Например: [http://example.com](url) -> [http://example.com](http://example.com)
|
||||
final wrongLinkRegex = RegExp(r'\[(https?://[^\]]+|www\.[^\]]+)\]\(([^\)]+)\)');
|
||||
text = text.replaceAllMapped(wrongLinkRegex, (match) {
|
||||
final urlInText = match.group(1)!;
|
||||
if (!urlInText.startsWith('http://') && !urlInText.startsWith('https://') && !urlInText.startsWith('www.')) {
|
||||
return match.group(0)!;
|
||||
}
|
||||
final fullUrl = urlInText.startsWith('www.') ? 'https://$urlInText' : urlInText;
|
||||
return '[$urlInText]($fullUrl)';
|
||||
});
|
||||
|
||||
// Находим все существующие markdown ссылки и их позиции
|
||||
final linkRegex = RegExp(r'\[([^\]]+)\]\(([^\)]+)\)');
|
||||
final existingLinks = <_LinkRange>[];
|
||||
for (final linkMatch in linkRegex.allMatches(text)) {
|
||||
existingLinks.add(_LinkRange(linkMatch.start, linkMatch.end));
|
||||
}
|
||||
|
||||
// Регулярное выражение для поиска URL (http://, https://, www.)
|
||||
final urlRegex = RegExp(r'(https?://[^\s\)]+|www\.[^\s\)]+)', caseSensitive: false);
|
||||
|
||||
// Заменяем URL на markdown ссылки, если они еще не являются ссылками
|
||||
String result = text;
|
||||
int offset = 0;
|
||||
for (final urlMatch in urlRegex.allMatches(text)) {
|
||||
// Проверяем, не находится ли URL внутри существующей markdown ссылки
|
||||
bool isInsideLink = existingLinks.any((link) =>
|
||||
urlMatch.start >= link.start && urlMatch.end <= link.end);
|
||||
if (!isInsideLink) {
|
||||
final url = urlMatch.group(0)!;
|
||||
final fullUrl = url.startsWith('www.') ? 'https://$url' : url;
|
||||
final replacement = '[$url]($fullUrl)';
|
||||
result = result.replaceRange(
|
||||
urlMatch.start + offset,
|
||||
urlMatch.end + offset,
|
||||
replacement,
|
||||
);
|
||||
offset += replacement.length - url.length;
|
||||
}
|
||||
}
|
||||
|
||||
// Гарантируем, что жирный текст будет распознан
|
||||
// Заменяем **текст** на <b>текст</b> вручную для надежности
|
||||
final boldRegex = RegExp(r'\*\*([^*]+)\*\*');
|
||||
result = result.replaceAllMapped(boldRegex, (match) {
|
||||
return '<b>${match.group(1)}</b>';
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
/// Вспомогательный класс для хранения диапазона позиций ссылки
|
||||
class _LinkRange {
|
||||
final int start;
|
||||
final int end;
|
||||
|
||||
_LinkRange(this.start, this.end);
|
||||
}
|
||||
124
lib/core/widgets/markdown_html_widget.dart
Normal file
124
lib/core/widgets/markdown_html_widget.dart
Normal file
@@ -0,0 +1,124 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_html/flutter_html.dart';
|
||||
import 'package:markdown/markdown.dart' as md;
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
import 'package:eatvio_app/core/utils/markdown_utils.dart';
|
||||
|
||||
/// Переиспользуемый виджет для отображения HTML из markdown текста
|
||||
class MarkdownHtmlWidget extends StatelessWidget {
|
||||
/// Markdown текст для отображения
|
||||
final String markdownText;
|
||||
|
||||
/// Ширина изображений (по умолчанию ширина экрана минус 32)
|
||||
final double? imageWidth;
|
||||
|
||||
/// Цвет текста для body и параграфов
|
||||
final Color? textColor;
|
||||
|
||||
/// Размер шрифта (по умолчанию 16)
|
||||
final double? fontSize;
|
||||
|
||||
/// Callback для обработки нажатий на ссылки
|
||||
final OnTap? onLinkTap;
|
||||
|
||||
const MarkdownHtmlWidget({
|
||||
super.key,
|
||||
required this.markdownText,
|
||||
this.imageWidth,
|
||||
this.textColor,
|
||||
this.fontSize,
|
||||
this.onLinkTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Обрабатываем markdown текст
|
||||
final processedText = MarkdownUtils.processMarkdown(markdownText);
|
||||
|
||||
// Конвертируем markdown в HTML
|
||||
final htmlData = md.markdownToHtml(
|
||||
processedText,
|
||||
extensionSet: md.ExtensionSet.gitHubFlavored,
|
||||
);
|
||||
|
||||
final screenWidth = MediaQuery.maybeOf(context)?.size.width ?? 400.0;
|
||||
final defaultImageWidth = imageWidth ?? (screenWidth - 32);
|
||||
final defaultTextColor = textColor ?? Colors.black87;
|
||||
final defaultFontSize = fontSize ?? 16.0;
|
||||
|
||||
return Html(
|
||||
data: htmlData,
|
||||
style: {
|
||||
"body": Style(
|
||||
margin: Margins.zero,
|
||||
padding: HtmlPaddings.zero,
|
||||
fontSize: FontSize(defaultFontSize),
|
||||
lineHeight: const LineHeight(1.5),
|
||||
color: defaultTextColor,
|
||||
),
|
||||
"p": Style(
|
||||
margin: Margins.only(bottom: 12),
|
||||
color: defaultTextColor,
|
||||
),
|
||||
"b, strong": Style(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.black,
|
||||
),
|
||||
"h1, h2, h3, h4, h5, h6": Style(
|
||||
margin: Margins.only(top: 16, bottom: 8),
|
||||
color: Colors.black,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
"h1": Style(fontSize: FontSize(24)),
|
||||
"h2": Style(fontSize: FontSize(20)),
|
||||
"h3": Style(fontSize: FontSize(18)),
|
||||
"ul, ol": Style(
|
||||
margin: Margins.only(bottom: 12),
|
||||
padding: HtmlPaddings.only(left: 16),
|
||||
),
|
||||
"li": Style(
|
||||
margin: Margins.only(bottom: 4),
|
||||
color: defaultTextColor,
|
||||
),
|
||||
"strong, b": Style(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.black,
|
||||
),
|
||||
"em, i": Style(fontStyle: FontStyle.italic),
|
||||
"code": Style(
|
||||
backgroundColor: Colors.grey.shade100,
|
||||
padding: HtmlPaddings.symmetric(horizontal: 4, vertical: 2),
|
||||
fontFamily: 'monospace',
|
||||
),
|
||||
"pre": Style(
|
||||
backgroundColor: Colors.grey.shade100,
|
||||
padding: HtmlPaddings.all(12),
|
||||
margin: Margins.only(bottom: 12),
|
||||
),
|
||||
"blockquote": Style(
|
||||
padding: HtmlPaddings.only(left: 16),
|
||||
margin: Margins.only(left: 8, bottom: 12),
|
||||
fontStyle: FontStyle.italic,
|
||||
color: Colors.grey.shade700,
|
||||
),
|
||||
"a": Style(
|
||||
color: Colors.blue,
|
||||
textDecoration: TextDecoration.underline,
|
||||
),
|
||||
"img": Style(width: Width(defaultImageWidth)),
|
||||
},
|
||||
onLinkTap: onLinkTap ?? ((url, attributes, element) async {
|
||||
if (url != null) {
|
||||
try {
|
||||
final uri = Uri.parse(url);
|
||||
if (await canLaunchUrl(uri)) {
|
||||
await launchUrl(uri, mode: LaunchMode.externalApplication);
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Error launching URL: $e');
|
||||
}
|
||||
}
|
||||
}) as OnTap,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,8 @@
|
||||
import 'dart:developer';
|
||||
|
||||
import 'package:eatvio_app/core/app_router.dart';
|
||||
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/routing/route_constants.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';
|
||||
@@ -12,7 +10,6 @@ import 'package:eatvio_app/features/auth/repositories/auth_repository.dart';
|
||||
import 'package:eatvio_app/features/profile/repositories/profile_repository.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_native_splash/flutter_native_splash.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
class AuthCubit extends Cubit<AuthState> {
|
||||
final AuthRepository authRepository;
|
||||
@@ -38,18 +35,12 @@ class AuthCubit extends Cubit<AuthState> {
|
||||
}
|
||||
|
||||
Future<void> initializeUser() async {
|
||||
await registerAnonymousPushToken();
|
||||
if (await TokenStorage.haveTokenInStorage()) {
|
||||
final profile = await profileRepository.getMyProfile();
|
||||
if (profile.emailVerifiedAt != null) {
|
||||
await registerPushToken();
|
||||
AppRouter.navigatorKey.currentContext?.go(RouteConstants.feedScreen);
|
||||
return;
|
||||
}
|
||||
AppRouter.navigatorKey.currentContext?.go(RouteConstants.verifyEmailScreen);
|
||||
} else {
|
||||
try {
|
||||
await registerAnonymousPushToken();
|
||||
FlutterNativeSplash.remove();
|
||||
FlutterNativeSplash.remove();
|
||||
} catch (e) {
|
||||
log('Error in initializeUser: $e');
|
||||
FlutterNativeSplash.remove();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import 'package:eatvio_app/features/auth/models/yandex_profile_model.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:eatvio_app/core/app_router.dart';
|
||||
import 'package:eatvio_app/core/env_config.dart';
|
||||
import 'package:eatvio_app/generated/l10n.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:vk_id/vk_id.dart';
|
||||
@@ -255,8 +256,9 @@ class SocialAuthFacade {
|
||||
if (!context.mounted) return null;
|
||||
return showDialog<Map<String, String>>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (_) {
|
||||
barrierDismissible: true,
|
||||
builder: (dialogContext) {
|
||||
final s = S.of(dialogContext);
|
||||
const borderRadius = BorderRadius.all(Radius.circular(16));
|
||||
return Dialog(
|
||||
insetPadding: const EdgeInsets.all(16),
|
||||
@@ -264,7 +266,21 @@ class SocialAuthFacade {
|
||||
elevation: 0,
|
||||
child: ClipRRect(
|
||||
borderRadius: borderRadius,
|
||||
child: SizedBox(height: 560, child: WebViewWidget(controller: controller)),
|
||||
child: SizedBox(
|
||||
height: MediaQuery.of(context).size.height * 0.7,
|
||||
child: Scaffold(
|
||||
appBar: AppBar(
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: () {
|
||||
Navigator.of(dialogContext).pop();
|
||||
},
|
||||
),
|
||||
title: Text(s.authorization),
|
||||
),
|
||||
body: WebViewWidget(controller: controller),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'package:eatvio_app/core/enums/complained_content.dart';
|
||||
import 'package:eatvio_app/core/utils/date_time_utils.dart';
|
||||
import 'package:eatvio_app/core/widgets/custom_popup.dart';
|
||||
import 'package:eatvio_app/core/widgets/loader.dart';
|
||||
import 'package:eatvio_app/core/widgets/markdown_html_widget.dart';
|
||||
import 'package:eatvio_app/features/blog_post/presentation/cubit/blog_post_cubit.dart';
|
||||
import 'package:eatvio_app/features/blog_post/presentation/cubit/blog_post_state.dart';
|
||||
import 'package:eatvio_app/core/comments/comments_cubit/comments_cubit.dart';
|
||||
@@ -12,13 +13,11 @@ import 'package:eatvio_app/features/feed/presentation/bloc/feed_cubit/feed_cubit
|
||||
import 'package:eatvio_app/features/feed/presentation/widgets/comments_widget.dart';
|
||||
import 'package:eatvio_app/core/locator_service.dart';
|
||||
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
||||
import 'package:eatvio_app/generated/l10n.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_html/flutter_html.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:share_plus/share_plus.dart';
|
||||
import 'package:markdown/markdown.dart' as md;
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
class BlogPostScreen extends StatefulWidget {
|
||||
final int id;
|
||||
@@ -79,98 +78,99 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
|
||||
resizeToAvoidBottomInset: true,
|
||||
bottomSheet: Container(
|
||||
padding: EdgeInsets.only(
|
||||
top: 12,
|
||||
bottom: 12 + MediaQuery.of(context).padding.bottom + 56, // 56 - высота bottomNavigationBar
|
||||
bottom: MediaQuery.of(context).padding.bottom + 8,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
border: Border(top: BorderSide(color: Colors.black.withValues(alpha: 0.1))),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
if (replyId != null)
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
context.read<CommentsCubit>().setReplyId(null);
|
||||
setState(() {
|
||||
replyId = null;
|
||||
textEditingController.clear();
|
||||
});
|
||||
FocusManager.instance.primaryFocus?.unfocus();
|
||||
},
|
||||
child: Container(
|
||||
decoration: BoxDecoration(color: appTheme.blueA400, shape: BoxShape.circle),
|
||||
height: 32,
|
||||
width: 32,
|
||||
child: const Icon(Icons.close_rounded, size: 18, color: Colors.white),
|
||||
),
|
||||
),
|
||||
if (replyId != null) const SizedBox(width: 8),
|
||||
SizedBox(
|
||||
width: MediaQuery.of(context).size.width - (replyId == null ? 64 : 104),
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(minHeight: 52),
|
||||
child: TextFormField(
|
||||
textCapitalization: TextCapitalization.sentences,
|
||||
onChanged: (_) {
|
||||
setState(() {});
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
if (replyId != null)
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
context.read<CommentsCubit>().setReplyId(null);
|
||||
setState(() {
|
||||
replyId = null;
|
||||
textEditingController.clear();
|
||||
});
|
||||
FocusManager.instance.primaryFocus?.unfocus();
|
||||
},
|
||||
controller: textEditingController,
|
||||
maxLines: 5,
|
||||
minLines: 1,
|
||||
keyboardType: TextInputType.multiline,
|
||||
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w400, color: Colors.black, height: 1.2),
|
||||
decoration: InputDecoration(
|
||||
isDense: false,
|
||||
// убираем true, чтобы padding учитывался корректно
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
|
||||
// вертикальный паддинг увеличен
|
||||
hintText: 'Комментарий',
|
||||
hintStyle: const TextStyle(fontSize: 16, fontWeight: FontWeight.w400, height: 1.2, color: Color(0xFFAEAEB2)),
|
||||
filled: true,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
borderSide: const BorderSide(color: Color(0xFFD1D1D6), width: 1),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
borderSide: const BorderSide(color: Color(0xFFD1D1D6), width: 1),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
borderSide: const BorderSide(color: Color(0xFFD1D1D6), width: 1),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(color: appTheme.blueA400, shape: BoxShape.circle),
|
||||
height: 32,
|
||||
width: 32,
|
||||
child: const Icon(Icons.close_rounded, size: 18, color: Colors.white),
|
||||
),
|
||||
),
|
||||
if (replyId != null) const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(minHeight: 52),
|
||||
child: TextFormField(
|
||||
textCapitalization: TextCapitalization.sentences,
|
||||
onChanged: (_) {
|
||||
setState(() {});
|
||||
},
|
||||
controller: textEditingController,
|
||||
maxLines: 5,
|
||||
minLines: 1,
|
||||
keyboardType: TextInputType.multiline,
|
||||
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w400, color: Colors.black, height: 1.2),
|
||||
decoration: InputDecoration(
|
||||
isDense: false,
|
||||
// убираем true, чтобы padding учитывался корректно
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
|
||||
// вертикальный паддинг увеличен
|
||||
hintText: S.of(context).comment_hint,
|
||||
hintStyle: const TextStyle(fontSize: 16, fontWeight: FontWeight.w400, height: 1.2, color: Color(0xFFAEAEB2)),
|
||||
filled: true,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
borderSide: const BorderSide(color: Color(0xFFD1D1D6), width: 1),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
borderSide: const BorderSide(color: Color(0xFFD1D1D6), width: 1),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
borderSide: const BorderSide(color: Color(0xFFD1D1D6), width: 1),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
if (textEditingController.text.length > 2) {
|
||||
commentsCubit.sendComment(
|
||||
id: widget.id.toString(),
|
||||
commentText: textEditingController.text,
|
||||
parentCommentId: replyId,
|
||||
commentSourceType: CommentSourceType.blogPost,
|
||||
);
|
||||
context.read<CommentsCubit>().setReplyId(null);
|
||||
setState(() {
|
||||
textEditingController.clear();
|
||||
replyId = null;
|
||||
});
|
||||
FocusManager.instance.primaryFocus?.unfocus();
|
||||
}
|
||||
},
|
||||
child: Container(
|
||||
decoration: BoxDecoration(color: textEditingController.text.length > 2 ? appTheme.blueA400 : Colors.grey, shape: BoxShape.circle),
|
||||
height: 32,
|
||||
width: 32,
|
||||
child: const Icon(Icons.send_rounded, size: 18, color: Colors.white),
|
||||
const SizedBox(width: 8),
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
if (textEditingController.text.length > 2) {
|
||||
commentsCubit.sendComment(
|
||||
id: widget.id.toString(),
|
||||
commentText: textEditingController.text,
|
||||
parentCommentId: replyId,
|
||||
commentSourceType: CommentSourceType.blogPost,
|
||||
);
|
||||
context.read<CommentsCubit>().setReplyId(null);
|
||||
setState(() {
|
||||
textEditingController.clear();
|
||||
replyId = null;
|
||||
});
|
||||
FocusManager.instance.primaryFocus?.unfocus();
|
||||
}
|
||||
},
|
||||
child: Container(
|
||||
decoration: BoxDecoration(color: textEditingController.text.length > 2 ? appTheme.blueA400 : Colors.grey, shape: BoxShape.circle),
|
||||
height: 32,
|
||||
width: 32,
|
||||
child: const Icon(Icons.send_rounded, size: 18, color: Colors.white),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
appBar: AppBar(
|
||||
@@ -236,104 +236,8 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
|
||||
if (blogPostState.blogPostModel?.description.isNotEmpty ?? false)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: Builder(
|
||||
builder: (context) {
|
||||
// Автоматически преобразуем URL в markdown ссылки
|
||||
String description = blogPostState.blogPostModel!.description;
|
||||
|
||||
// Сначала исправляем неправильный синтаксис markdown ссылок, где URL в тексте, а не в href
|
||||
// Например: [http://example.com](url) -> [http://example.com](http://example.com)
|
||||
final wrongLinkRegex = RegExp(r'\[(https?://[^\]]+|www\.[^\]]+)\]\(([^\)]+)\)');
|
||||
description = description.replaceAllMapped(wrongLinkRegex, (match) {
|
||||
final urlInText = match.group(1)!;
|
||||
// Если текст ссылки является URL, используем его как href
|
||||
if (!urlInText.startsWith('http://') && !urlInText.startsWith('https://') && !urlInText.startsWith('www.')) {
|
||||
return match.group(0)!; // Оставляем как есть, если это не URL
|
||||
}
|
||||
final fullUrl = urlInText.startsWith('www.') ? 'https://$urlInText' : urlInText;
|
||||
// Используем URL как href, а текст ссылки - это сам URL
|
||||
return '[$urlInText]($fullUrl)';
|
||||
});
|
||||
|
||||
// Теперь находим все существующие markdown ссылки и их позиции
|
||||
final linkRegex = RegExp(r'\[([^\]]+)\]\(([^\)]+)\)');
|
||||
final existingLinks = <_LinkRange>[];
|
||||
|
||||
for (final linkMatch in linkRegex.allMatches(description)) {
|
||||
existingLinks.add(_LinkRange(linkMatch.start, linkMatch.end));
|
||||
}
|
||||
|
||||
// Регулярное выражение для поиска URL (http://, https://, www.)
|
||||
final urlRegex = RegExp(r'(https?://[^\s\)]+|www\.[^\s\)]+)', caseSensitive: false);
|
||||
|
||||
// Заменяем URL на markdown ссылки, если они еще не являются ссылками
|
||||
String result = description;
|
||||
int offset = 0;
|
||||
|
||||
for (final urlMatch in urlRegex.allMatches(description)) {
|
||||
// Проверяем, не находится ли URL внутри существующей markdown ссылки
|
||||
bool isInsideLink = existingLinks.any((link) => urlMatch.start >= link.start && urlMatch.end <= link.end);
|
||||
|
||||
if (!isInsideLink) {
|
||||
final url = urlMatch.group(0)!;
|
||||
final fullUrl = url.startsWith('www.') ? 'https://$url' : url;
|
||||
final replacement = '[$url]($fullUrl)';
|
||||
result = result.replaceRange(urlMatch.start + offset, urlMatch.end + offset, replacement);
|
||||
offset += replacement.length - url.length;
|
||||
}
|
||||
}
|
||||
|
||||
description = result;
|
||||
|
||||
final htmlData = md.markdownToHtml(description.replaceAll('\n', '<br>'), extensionSet: md.ExtensionSet.commonMark);
|
||||
debugPrint('Markdown input: ${blogPostState.blogPostModel!.description}');
|
||||
debugPrint('Processed markdown: $description');
|
||||
debugPrint('HTML output: $htmlData');
|
||||
return Html(
|
||||
data: htmlData,
|
||||
style: {
|
||||
"body": Style(margin: Margins.zero, padding: HtmlPaddings.zero, fontSize: FontSize(16), lineHeight: const LineHeight(1.5)),
|
||||
"p": Style(margin: Margins.only(bottom: 12), color: Colors.black87),
|
||||
"h1, h2, h3, h4, h5, h6": Style(margin: Margins.only(top: 16, bottom: 8), color: Colors.black, fontWeight: FontWeight.bold),
|
||||
"h1": Style(fontSize: FontSize(24)),
|
||||
"h2": Style(fontSize: FontSize(20)),
|
||||
"h3": Style(fontSize: FontSize(18)),
|
||||
"ul, ol": Style(margin: Margins.only(bottom: 12), padding: HtmlPaddings.only(left: 16)),
|
||||
"li": Style(margin: Margins.only(bottom: 4), color: Colors.black87),
|
||||
"strong, b": Style(fontWeight: FontWeight.bold, color: Colors.black),
|
||||
"em, i": Style(fontStyle: FontStyle.italic),
|
||||
"code": Style(
|
||||
backgroundColor: Colors.grey.shade100,
|
||||
padding: HtmlPaddings.symmetric(horizontal: 4, vertical: 2),
|
||||
fontFamily: 'monospace',
|
||||
),
|
||||
"pre": Style(backgroundColor: Colors.grey.shade100, padding: HtmlPaddings.all(12), margin: Margins.only(bottom: 12)),
|
||||
"blockquote": Style(
|
||||
padding: HtmlPaddings.only(left: 16),
|
||||
margin: Margins.only(left: 8, bottom: 12),
|
||||
fontStyle: FontStyle.italic,
|
||||
color: Colors.grey.shade700,
|
||||
),
|
||||
"a": Style(color: Colors.blue, textDecoration: TextDecoration.underline),
|
||||
"img": Style(width: Width(MediaQuery.of(context).size.width - 32)),
|
||||
},
|
||||
onLinkTap: (url, attributes, element) async {
|
||||
debugPrint('Link tapped: $url');
|
||||
if (url != null) {
|
||||
try {
|
||||
final uri = Uri.parse(url);
|
||||
if (await canLaunchUrl(uri)) {
|
||||
await launchUrl(uri, mode: LaunchMode.externalApplication);
|
||||
} else {
|
||||
debugPrint('Cannot launch URL: $url');
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Error launching URL: $e');
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
},
|
||||
child: MarkdownHtmlWidget(
|
||||
markdownText: blogPostState.blogPostModel!.description,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
@@ -436,10 +340,3 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _LinkRange {
|
||||
final int start;
|
||||
final int end;
|
||||
|
||||
_LinkRange(this.start, this.end);
|
||||
}
|
||||
|
||||
@@ -5,7 +5,8 @@ import 'package:eatvio_app/core/utils/image_constant.dart';
|
||||
import 'package:eatvio_app/features/dev_tools/presentation/dev_tools_screen.dart';
|
||||
import 'package:eatvio_app/features/notifications/presentation/notifications_cubit/notifications_cubit.dart';
|
||||
import 'package:eatvio_app/features/notifications/presentation/notifications_cubit/notifications_state.dart';
|
||||
import 'package:eatvio_app/features/profile/models/profile_model.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/chat/presentation/bloc/chats_cubit/chats_cubit.dart';
|
||||
import 'package:eatvio_app/features/chat/presentation/bloc/chats_cubit/chats_state.dart';
|
||||
import 'package:eatvio_app/generated/l10n.dart';
|
||||
@@ -16,13 +17,11 @@ import 'package:flutter_svg/flutter_svg.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
class BottomNavigationBarWidget extends StatefulWidget {
|
||||
final ProfileModel profileModel;
|
||||
final Widget child;
|
||||
|
||||
const BottomNavigationBarWidget({
|
||||
super.key,
|
||||
required this.child,
|
||||
required this.profileModel,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -31,15 +30,12 @@ class BottomNavigationBarWidget extends StatefulWidget {
|
||||
|
||||
class _BottomNavigationBarWidgetState extends State<BottomNavigationBarWidget> {
|
||||
int currentBottomNavBarIndex = 1;
|
||||
late final List<String> pages;
|
||||
List<String>? pages;
|
||||
int tapsCount = 0;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
if (widget.profileModel.roles.contains('consultant')) {
|
||||
pages = [
|
||||
List<String> _getPages(bool isConsultant) {
|
||||
if (isConsultant) {
|
||||
return [
|
||||
'/my-profile-screen',
|
||||
'/feed-screen',
|
||||
'/diary-screen',
|
||||
@@ -49,7 +45,7 @@ class _BottomNavigationBarWidgetState extends State<BottomNavigationBarWidget> {
|
||||
'/menu-screen',
|
||||
];
|
||||
} else {
|
||||
pages = [
|
||||
return [
|
||||
'/my-profile-screen',
|
||||
'/feed-screen',
|
||||
'/diary-screen',
|
||||
@@ -61,7 +57,8 @@ class _BottomNavigationBarWidgetState extends State<BottomNavigationBarWidget> {
|
||||
}
|
||||
|
||||
void _onItemTapped(int index) async {
|
||||
if (pages.length == 7) {
|
||||
if (pages == null) return;
|
||||
if (pages!.length == 7) {
|
||||
if (index == 6) {
|
||||
tapsCount++;
|
||||
if (tapsCount == 7) {
|
||||
@@ -82,108 +79,117 @@ class _BottomNavigationBarWidgetState extends State<BottomNavigationBarWidget> {
|
||||
setState(() {
|
||||
currentBottomNavBarIndex = index;
|
||||
});
|
||||
GoRouter.of(context).replace(pages[currentBottomNavBarIndex]);
|
||||
GoRouter.of(context).replace(pages![currentBottomNavBarIndex]);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final s = S.of(context);
|
||||
return Scaffold(
|
||||
body: widget.child,
|
||||
bottomNavigationBar: BottomNavigationBar(
|
||||
type: BottomNavigationBarType.fixed,
|
||||
onTap: _onItemTapped,
|
||||
selectedFontSize: 10,
|
||||
unselectedFontSize: 10,
|
||||
selectedItemColor: appTheme.blueA400,
|
||||
currentIndex: currentBottomNavBarIndex,
|
||||
items: [
|
||||
BottomNavigationBarItem(
|
||||
label: '',
|
||||
icon: CircleAvatar(
|
||||
foregroundImage: NetworkImage(
|
||||
widget.profileModel.profilePhotoUrl.contains('https') ? widget.profileModel.profilePhotoUrl : sl<AppConfig>().baseUrl + widget.profileModel.profilePhotoUrl,
|
||||
),
|
||||
backgroundImage: const AssetImage('assets/images/no_avatar.png'),
|
||||
radius: 16,
|
||||
),
|
||||
),
|
||||
BottomNavigationBarItem(
|
||||
label: 'Лента',
|
||||
icon: Padding(
|
||||
padding: const EdgeInsets.only(bottom: 2),
|
||||
child: SvgPicture.asset(
|
||||
colorFilter: const ColorFilter.mode(Colors.grey, BlendMode.srcIn),
|
||||
width: 20,
|
||||
height: 20,
|
||||
ImageConstant.icnFeed,
|
||||
),
|
||||
),
|
||||
activeIcon: Padding(
|
||||
padding: const EdgeInsets.only(bottom: 2),
|
||||
child: SvgPicture.asset(
|
||||
ImageConstant.icnFeed,
|
||||
width: 20,
|
||||
height: 20,
|
||||
colorFilter: ColorFilter.mode(
|
||||
appTheme.blueA400,
|
||||
BlendMode.srcIn,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
BottomNavigationBarItem(
|
||||
label: s.diary,
|
||||
icon: Padding(
|
||||
padding: const EdgeInsets.only(bottom: 2),
|
||||
child: SvgPicture.asset(
|
||||
colorFilter: const ColorFilter.mode(Colors.grey, BlendMode.srcIn),
|
||||
ImageConstant.icnDiary,
|
||||
width: 20,
|
||||
height: 20,
|
||||
),
|
||||
),
|
||||
activeIcon: Padding(
|
||||
padding: const EdgeInsets.only(bottom: 2),
|
||||
child: SvgPicture.asset(
|
||||
ImageConstant.icnDiary,
|
||||
width: 20,
|
||||
height: 20,
|
||||
colorFilter: ColorFilter.mode(
|
||||
appTheme.blueA400,
|
||||
BlendMode.srcIn,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (widget.profileModel.roles.contains('consultant'))
|
||||
BottomNavigationBarItem(
|
||||
label: 'Консультации',
|
||||
icon: Padding(
|
||||
padding: const EdgeInsets.only(bottom: 2),
|
||||
child: SvgPicture.asset(
|
||||
colorFilter: const ColorFilter.mode(Colors.grey, BlendMode.srcIn),
|
||||
'assets/icons/consultations.svg',
|
||||
width: 20,
|
||||
height: 20,
|
||||
),
|
||||
),
|
||||
activeIcon: Padding(
|
||||
padding: const EdgeInsets.only(bottom: 2),
|
||||
child: SvgPicture.asset(
|
||||
'assets/icons/consultations.svg',
|
||||
width: 20,
|
||||
height: 20,
|
||||
colorFilter: ColorFilter.mode(
|
||||
appTheme.blueA400,
|
||||
BlendMode.srcIn,
|
||||
return BlocBuilder<ProfileAvatarCubit, ProfileAvatarState>(
|
||||
builder: (BuildContext context, ProfileAvatarState state) {
|
||||
if (state is ProfileAvatarStateLoaded) {
|
||||
final profileModel = state.profile;
|
||||
final isConsultant = profileModel.roles.contains('consultant');
|
||||
|
||||
// Инициализируем pages на основе profileModel
|
||||
pages ??= _getPages(isConsultant);
|
||||
|
||||
return Scaffold(
|
||||
body: widget.child,
|
||||
bottomNavigationBar: BottomNavigationBar(
|
||||
type: BottomNavigationBarType.fixed,
|
||||
onTap: _onItemTapped,
|
||||
selectedFontSize: 10,
|
||||
unselectedFontSize: 10,
|
||||
selectedItemColor: appTheme.blueA400,
|
||||
currentIndex: currentBottomNavBarIndex,
|
||||
items: [
|
||||
BottomNavigationBarItem(
|
||||
label: '',
|
||||
icon: CircleAvatar(
|
||||
foregroundImage: NetworkImage(
|
||||
profileModel.profilePhotoUrl.contains('https') ? profileModel.profilePhotoUrl : sl<AppConfig>().baseUrl + profileModel.profilePhotoUrl,
|
||||
),
|
||||
backgroundImage: const AssetImage('assets/images/no_avatar.png'),
|
||||
radius: 16,
|
||||
),
|
||||
),
|
||||
BottomNavigationBarItem(
|
||||
label: 'Лента',
|
||||
icon: Padding(
|
||||
padding: const EdgeInsets.only(bottom: 2),
|
||||
child: SvgPicture.asset(
|
||||
colorFilter: const ColorFilter.mode(Colors.grey, BlendMode.srcIn),
|
||||
width: 20,
|
||||
height: 20,
|
||||
ImageConstant.icnFeed,
|
||||
),
|
||||
),
|
||||
BottomNavigationBarItem(
|
||||
label: s.chat,
|
||||
activeIcon: Padding(
|
||||
padding: const EdgeInsets.only(bottom: 2),
|
||||
child: SvgPicture.asset(
|
||||
ImageConstant.icnFeed,
|
||||
width: 20,
|
||||
height: 20,
|
||||
colorFilter: ColorFilter.mode(
|
||||
appTheme.blueA400,
|
||||
BlendMode.srcIn,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
BottomNavigationBarItem(
|
||||
label: s.diary,
|
||||
icon: Padding(
|
||||
padding: const EdgeInsets.only(bottom: 2),
|
||||
child: SvgPicture.asset(
|
||||
colorFilter: const ColorFilter.mode(Colors.grey, BlendMode.srcIn),
|
||||
ImageConstant.icnDiary,
|
||||
width: 20,
|
||||
height: 20,
|
||||
),
|
||||
),
|
||||
activeIcon: Padding(
|
||||
padding: const EdgeInsets.only(bottom: 2),
|
||||
child: SvgPicture.asset(
|
||||
ImageConstant.icnDiary,
|
||||
width: 20,
|
||||
height: 20,
|
||||
colorFilter: ColorFilter.mode(
|
||||
appTheme.blueA400,
|
||||
BlendMode.srcIn,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (profileModel.roles.contains('consultant'))
|
||||
BottomNavigationBarItem(
|
||||
label: 'Консультации',
|
||||
icon: Padding(
|
||||
padding: const EdgeInsets.only(bottom: 2),
|
||||
child: SvgPicture.asset(
|
||||
colorFilter: const ColorFilter.mode(Colors.grey, BlendMode.srcIn),
|
||||
'assets/icons/consultations.svg',
|
||||
width: 20,
|
||||
height: 20,
|
||||
),
|
||||
),
|
||||
activeIcon: Padding(
|
||||
padding: const EdgeInsets.only(bottom: 2),
|
||||
child: SvgPicture.asset(
|
||||
'assets/icons/consultations.svg',
|
||||
width: 20,
|
||||
height: 20,
|
||||
colorFilter: ColorFilter.mode(
|
||||
appTheme.blueA400,
|
||||
BlendMode.srcIn,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
BottomNavigationBarItem(
|
||||
label: s.chat,
|
||||
icon: BlocBuilder<ChatsCubit, ChatsState>(
|
||||
builder: (BuildContext context, ChatsState state) {
|
||||
final hasUnreadMessages = state.chatsWithUnreadMessagesCount > 0;
|
||||
@@ -206,7 +212,7 @@ class _BottomNavigationBarWidgetState extends State<BottomNavigationBarWidget> {
|
||||
ImageConstant.icnChat,
|
||||
width: 20,
|
||||
height: 20,
|
||||
colorFilter: pages[currentBottomNavBarIndex] == '/chat-select-screen'
|
||||
colorFilter: pages![currentBottomNavBarIndex] == '/chat-select-screen'
|
||||
? ColorFilter.mode(
|
||||
appTheme.blueA400,
|
||||
BlendMode.srcIn,
|
||||
@@ -217,9 +223,9 @@ class _BottomNavigationBarWidgetState extends State<BottomNavigationBarWidget> {
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
BottomNavigationBarItem(
|
||||
label: 'Уведомления',
|
||||
),
|
||||
BottomNavigationBarItem(
|
||||
label: 'Уведомления',
|
||||
icon: BlocBuilder<NotificationsCubit, NotificationsState>(
|
||||
builder: (BuildContext context, NotificationsState state) {
|
||||
return badges.Badge(
|
||||
@@ -237,7 +243,7 @@ class _BottomNavigationBarWidgetState extends State<BottomNavigationBarWidget> {
|
||||
padding: const EdgeInsets.only(bottom: 2),
|
||||
child: SvgPicture.asset(
|
||||
ImageConstant.icnBell,
|
||||
colorFilter: pages[currentBottomNavBarIndex] == '/notifications-screen'
|
||||
colorFilter: pages![currentBottomNavBarIndex] == '/notifications-screen'
|
||||
? ColorFilter.mode(
|
||||
appTheme.blueA400,
|
||||
BlendMode.srcIn,
|
||||
@@ -251,15 +257,22 @@ class _BottomNavigationBarWidgetState extends State<BottomNavigationBarWidget> {
|
||||
},
|
||||
),
|
||||
),
|
||||
BottomNavigationBarItem(
|
||||
label: '',
|
||||
icon: SvgPicture.asset(
|
||||
ImageConstant.imgLogo,
|
||||
height: 32,
|
||||
BottomNavigationBarItem(
|
||||
label: '',
|
||||
icon: SvgPicture.asset(
|
||||
ImageConstant.imgLogo,
|
||||
height: 32,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
// Показываем child даже когда профиль еще загружается, чтобы экраны могли инициализироваться
|
||||
return Scaffold(
|
||||
body: widget.child,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,19 @@ class ChatsScreen extends StatefulWidget {
|
||||
class _ChatsScreenState extends State<ChatsScreen> {
|
||||
final searchController = TextEditingController();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// Инициализация ChatsCubit при первом открытии экрана
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
final cubit = context.read<ChatsCubit>();
|
||||
final state = cubit.state;
|
||||
if (state.state == BaseState.initial) {
|
||||
cubit.getChatRoomsWithLoading();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocBuilder<ChatsCubit, ChatsState>(
|
||||
|
||||
@@ -105,15 +105,6 @@ class _DietPreviewScreenState extends State<DietPreviewScreen> {
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
InfoBouble(
|
||||
backgroundColor: appTheme.whiteA700,
|
||||
children: [
|
||||
SvgPicture.asset('assets/icons/comment2.svg', width: 13, height: 12),
|
||||
const SizedBox(width: 8),
|
||||
Text(widget.diet.totalComments.toString(), style: TextStyles.textXSSemibold.copyWith(color: appTheme.black)),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -157,16 +148,11 @@ class _DietPreviewScreenState extends State<DietPreviewScreen> {
|
||||
},
|
||||
itemCount: widget.diet.dailySchedule.length,
|
||||
),
|
||||
MainBackgroundBouble(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
const MainBackgroundBouble(
|
||||
padding: EdgeInsets.symmetric(vertical: 16),
|
||||
child: Column(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Text('0 ${getCommentForm(widget.diet.totalComments, s)}', style: TextStyles.textMMedium.copyWith(color: appTheme.gray500)),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
SizedBox(height: 16),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -237,7 +237,16 @@ class CreateRecipeCubit extends Cubit<CreateRecipeState> {
|
||||
final isActiveCookTimeValid = validateActiveCookTime();
|
||||
final isEnergyPerServingsValid = validateEnergyPerServing();
|
||||
|
||||
return isTitleValid && isDescValid && isImageValid && areServingsValid && isCookTimeValid && isActiveCookTimeValid && isEnergyPerServingsValid;
|
||||
final isStepsValid = validateSteps();
|
||||
|
||||
return isTitleValid &&
|
||||
isDescValid &&
|
||||
isImageValid &&
|
||||
areServingsValid &&
|
||||
isCookTimeValid &&
|
||||
isActiveCookTimeValid &&
|
||||
isEnergyPerServingsValid &&
|
||||
isStepsValid;
|
||||
}
|
||||
|
||||
bool validateTitle() {
|
||||
@@ -328,4 +337,20 @@ class CreateRecipeCubit extends Cubit<CreateRecipeState> {
|
||||
Future<List<GlossaryModel>> getGlossaryListByQuery(String query) async {
|
||||
return await glossaryRepository.getGlossaryListByQuery(query);
|
||||
}
|
||||
|
||||
bool validateSteps() {
|
||||
bool isValid = true;
|
||||
for (var step in state.entity.steps) {
|
||||
if (step.description == null || step.description!.trim().isEmpty) {
|
||||
isValid = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!isValid) {
|
||||
emit(state.copyWith(stepsValidationError: 'Заполните описание для всех шагов'));
|
||||
} else {
|
||||
emit(state.copyWith(stepsValidationError: null));
|
||||
}
|
||||
return isValid;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ abstract class CreateRecipeState with _$CreateRecipeState {
|
||||
String? servingsValidationError,
|
||||
String? energyPerServingValidationError,
|
||||
String? imageValidationError,
|
||||
String? stepsValidationError,
|
||||
}) = _CreateRecipeState;
|
||||
|
||||
}
|
||||
|
||||
@@ -131,6 +131,7 @@ class _AddPortionWidgetState extends State<AddPortionWidget> {
|
||||
if (widget.portionIndex != null)
|
||||
InkWell(
|
||||
onTap: () {
|
||||
FocusScope.of(context).unfocus();
|
||||
widget.createRecipeCubit.removeIngredient(widget.portionIndex!);
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
@@ -189,6 +190,7 @@ class _AddPortionWidgetState extends State<AddPortionWidget> {
|
||||
Ingredient(name: portion.name, unit: portion.portion.getValue(), portion: portion.quantity.toString(), productId: glossary.id),
|
||||
);
|
||||
if (context.mounted) {
|
||||
FocusScope.of(context).unfocus();
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
} else {}
|
||||
|
||||
@@ -75,6 +75,7 @@ class _IngredientsWidgetState extends State<IngredientsWidget> {
|
||||
ListTile(
|
||||
key: AppKeys.recipeCreateScreenAddIngredientButton,
|
||||
onTap: () {
|
||||
FocusManager.instance.primaryFocus?.unfocus();
|
||||
nameController.clear();
|
||||
gramsController.clear();
|
||||
pcsController.clear();
|
||||
|
||||
@@ -55,6 +55,13 @@ class _StepsWidgetState extends State<StepsWidget> {
|
||||
),
|
||||
),
|
||||
),
|
||||
if (state.stepsValidationError != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
state.stepsValidationError!,
|
||||
style: const TextStyle(color: Colors.red, fontSize: 12),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 12),
|
||||
ListView.separated(
|
||||
shrinkWrap: true,
|
||||
|
||||
@@ -18,5 +18,8 @@ abstract class DiaryDayEntity with _$DiaryDayEntity {
|
||||
@JsonKey(name: "day_of_chosen_diet") required int? dayOfChosenDiet,
|
||||
@JsonKey(name: "chosen_diet", fromJson: parseDiet) required DiaryDietModel? diet,
|
||||
}) = _DiaryDayEntity;
|
||||
|
||||
|
||||
const DiaryDayEntity._();
|
||||
|
||||
bool get hasProducts => meals.any((meal) => meal.portions.isNotEmpty);
|
||||
}
|
||||
|
||||
@@ -50,11 +50,14 @@ class DiaryCubit extends Cubit<DiaryState> {
|
||||
|
||||
Future<bool> setSelectedDay(DateTime day) async {
|
||||
final prevDay = selectedDay;
|
||||
final prevMeal = selectedMeal;
|
||||
selectedDay = day;
|
||||
selectedMeal = 1;
|
||||
final canSelect = await fetchDiaryV2(needLoading: true);
|
||||
|
||||
if (!canSelect) {
|
||||
selectedDay = prevDay;
|
||||
selectedMeal = prevMeal;
|
||||
}
|
||||
|
||||
return canSelect;
|
||||
@@ -62,34 +65,40 @@ class DiaryCubit extends Cubit<DiaryState> {
|
||||
|
||||
Future<bool> fetchDiaryV2({bool needLoading = false, int? diaryId}) async {
|
||||
final currentState = state;
|
||||
if (needLoading) {
|
||||
emit(const DiaryState.loading());
|
||||
}
|
||||
if (diaryId != null) {
|
||||
final diaryDayById = await diaryRepository.getDiaryDay(diaryId);
|
||||
final diaryEntity = diaryDayById.toEntity();
|
||||
try {
|
||||
if (needLoading) {
|
||||
emit(const DiaryState.loading());
|
||||
}
|
||||
if (diaryId != null) {
|
||||
final diaryDayById = await diaryRepository.getDiaryDay(diaryId);
|
||||
final diaryEntity = diaryDayById.toEntity();
|
||||
if (diaryEntity != null) {
|
||||
setDiary(diaryDay: diaryEntity, diaryId: diaryId);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
final diaryDay = await getDiary();
|
||||
if (diaryDay == null) {
|
||||
emit(const DiaryState.error(errorMessage: 'Diary not found'));
|
||||
return true;
|
||||
}
|
||||
|
||||
final diaryEntity = diaryDay.toEntity();
|
||||
if (diaryEntity != null) {
|
||||
setDiary(diaryDay: diaryEntity, diaryId: diaryId);
|
||||
return true;
|
||||
}
|
||||
if (diaryDay.typeOfDiary.hasChosenDiet == true && diaryDay.typeOfDiary.diaryState == null) {
|
||||
setDiaryForDietOnly(diaryDay.toDiaryFutureDayEntity());
|
||||
return true;
|
||||
}
|
||||
emit(currentState);
|
||||
return false;
|
||||
} catch (e, st) {
|
||||
log(e.toString(), stackTrace: st);
|
||||
emit(DiaryState.error(errorMessage: e.toString()));
|
||||
return false;
|
||||
}
|
||||
final diaryDay = await getDiary();
|
||||
if (diaryDay == null) {
|
||||
emit(const DiaryState.error(errorMessage: 'Diary not found'));
|
||||
return true;
|
||||
}
|
||||
|
||||
final diaryEntity = diaryDay.toEntity();
|
||||
if (diaryEntity != null) {
|
||||
setDiary(diaryDay: diaryEntity, diaryId: diaryId);
|
||||
return true;
|
||||
}
|
||||
if (diaryDay.typeOfDiary.hasChosenDiet == true && diaryDay.typeOfDiary.diaryState == null) {
|
||||
setDiaryForDietOnly(diaryDay.toDiaryFutureDayEntity());
|
||||
return true;
|
||||
}
|
||||
emit(currentState);
|
||||
return false;
|
||||
}
|
||||
|
||||
Future<void> updateCurrentDiary() async {
|
||||
@@ -109,13 +118,16 @@ class DiaryCubit extends Cubit<DiaryState> {
|
||||
await diaryRepository.createDiaryDay(day: selectedDay.toISO8601WithTimeZoneOffset());
|
||||
final diaryDay = await diaryRepository.getDiaryDayV2(day: selectedDay.toISO8601WithTimeZoneOffset());
|
||||
return diaryDay;
|
||||
} on DioException catch (e) {
|
||||
if (e.response?.statusCode == 422) {
|
||||
final diaryDay = await diaryRepository.getDiaryDayV2(day: selectedDay.toISO8601WithTimeZoneOffset());
|
||||
return diaryDay;
|
||||
} catch (e, st) {
|
||||
log(e.toString(), stackTrace: st);
|
||||
if (e is DioException) {
|
||||
if (e.response?.statusCode == 422) {
|
||||
final diaryDay = await diaryRepository.getDiaryDayV2(day: selectedDay.toISO8601WithTimeZoneOffset());
|
||||
return diaryDay;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
void setSelectedMeal(int meal) {
|
||||
|
||||
@@ -127,6 +127,7 @@ class _DiaryScreenContentState extends State<DiaryScreenContent> {
|
||||
child: _isDiaryFinishing
|
||||
? const Center(child: Loader())
|
||||
: FinishDayButtonWidget(
|
||||
hasProducts: state.diary.hasProducts,
|
||||
onPressed: () async {
|
||||
setState(() {
|
||||
_isDiaryFinishing = true;
|
||||
|
||||
@@ -165,6 +165,11 @@ class _AddProductModalState extends State<AddProductModal> {
|
||||
if (widget.portion != null) const SizedBox(width: 8),
|
||||
InkWell(
|
||||
onTap: () {
|
||||
// Автоматически выбираем первый элемент, если текст точно совпадает
|
||||
if (selectedGlossary == null && glossariesList.isNotEmpty && productNameController.text.trim().toLowerCase() == glossariesList[0].name.toLowerCase()) {
|
||||
selectedGlossary = glossariesList[0];
|
||||
}
|
||||
|
||||
if (widget.portion == null) {
|
||||
widget.diaryCubit.addProductToMeal(
|
||||
diaryId: widget.state.diary.id,
|
||||
|
||||
@@ -11,55 +11,53 @@ class ChosenPlanWidget extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocBuilder<DiaryCubit, DiaryState>(builder: (context, state) {
|
||||
if (state is DiaryStateLoaded) {
|
||||
if (state.diary.typeOfDiary.hasChosenPlan) {
|
||||
return ChosenPlanWidgetContent(
|
||||
currentPlanDay: state.chosenPlanCurrentDay ?? 0,
|
||||
planName: state.chosenPlanName ?? '',
|
||||
planDuration: state.chosenPlanDuration ?? 0,
|
||||
);
|
||||
return BlocBuilder<DiaryCubit, DiaryState>(
|
||||
builder: (context, state) {
|
||||
if (state is DiaryStateLoaded) {
|
||||
if (state.diary.typeOfDiary.hasChosenPlan) {
|
||||
return ChosenPlanWidgetContent(
|
||||
currentPlanDay: state.chosenPlanCurrentDay ?? 0,
|
||||
planName: state.chosenPlanName ?? '',
|
||||
planDuration: state.chosenPlanDuration ?? 0,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return const SizedBox.shrink();
|
||||
});
|
||||
return const SizedBox.shrink();
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ChosenPlanWidgetContent extends StatelessWidget {
|
||||
const ChosenPlanWidgetContent({
|
||||
super.key,
|
||||
required this.currentPlanDay,
|
||||
required this.planName,
|
||||
required this.planDuration,
|
||||
});
|
||||
const ChosenPlanWidgetContent({super.key, required this.currentPlanDay, required this.planName, required this.planDuration});
|
||||
final int currentPlanDay;
|
||||
final String planName;
|
||||
final int planDuration;
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final size = MediaQuery.sizeOf(context);
|
||||
final s = S.of(context);
|
||||
return Column(
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: size.width * 0.4,
|
||||
child: Text(planName, style: TextStyles.textLMedium.copyWith(color: appTheme.black)),
|
||||
Expanded(
|
||||
child: Text(
|
||||
planName,
|
||||
style: TextStyles.textLMedium.copyWith(color: appTheme.black),
|
||||
softWrap: true,
|
||||
overflow: TextOverflow.visible,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
const SizedBox(width: 8),
|
||||
Container(
|
||||
decoration: BoxDecoration(color: appTheme.dividerColor, borderRadius: BorderRadius.circular(12)),
|
||||
padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 12),
|
||||
child: Text('${s.day} $currentPlanDay из $planDuration', style: TextStyles.textMSemibold.copyWith(color: appTheme.gray)),
|
||||
)
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(
|
||||
height: 16,
|
||||
)
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,20 +1,89 @@
|
||||
import 'package:eatvio_app/core/theme/custom_text_style.dart';
|
||||
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
||||
import 'package:eatvio_app/core/widgets/bottom_sheet_handler_bar.dart';
|
||||
import 'package:eatvio_app/core/widgets/bottom_sheet_with_blur.dart';
|
||||
import 'package:eatvio_app/core/widgets/custom_outlined_button.dart';
|
||||
import 'package:eatvio_app/core/widgets/show_error_dialog.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class FinishDayButtonWidget extends StatefulWidget {
|
||||
final Future<void> Function() onPressed;
|
||||
final bool hasProducts;
|
||||
|
||||
const FinishDayButtonWidget({super.key, required this.onPressed});
|
||||
const FinishDayButtonWidget({super.key, required this.onPressed, required this.hasProducts});
|
||||
|
||||
@override
|
||||
State<FinishDayButtonWidget> createState() => _FinishDayButtonWidgetState();
|
||||
}
|
||||
|
||||
class _FinishDayButtonWidgetState extends State<FinishDayButtonWidget> {
|
||||
void _onTap() {
|
||||
if (!widget.hasProducts) {
|
||||
showErrorDialog(
|
||||
context,
|
||||
title: 'Не добавлено продуктов',
|
||||
description: 'Добавьте хотя бы один продукт в любой из приёмов пищи, чтобы закончить день.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
_showConfirmationBottomSheet();
|
||||
}
|
||||
|
||||
void _showConfirmationBottomSheet() {
|
||||
showBottomSheetWithBlur(
|
||||
context: context,
|
||||
height: null,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12.0),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const SizedBox(height: 8),
|
||||
const BottomSheetHandlerBar(),
|
||||
const SizedBox(height: 20),
|
||||
Text('Закончить день', style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black)),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'После завершения дня, его уже нельзя будет отредактировать',
|
||||
style: TextStyles.textSMedium.copyWith(color: appTheme.black),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
CustomOutlinedButton(
|
||||
text: 'Закончить',
|
||||
height: 52,
|
||||
buttonTextStyle: TextStyles.textMSemibold.copyWith(color: appTheme.whiteA700),
|
||||
buttonStyle: ButtonStyle(
|
||||
side: const WidgetStatePropertyAll(BorderSide.none),
|
||||
backgroundColor: WidgetStatePropertyAll(appTheme.blueA400),
|
||||
),
|
||||
onPressed: () async {
|
||||
Navigator.of(context).pop();
|
||||
await widget.onPressed();
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
CustomOutlinedButton(
|
||||
text: 'Отмена',
|
||||
height: 52,
|
||||
buttonTextStyle: TextStyles.textMSemibold.copyWith(color: appTheme.black),
|
||||
buttonStyle: const ButtonStyle(
|
||||
side: WidgetStatePropertyAll(BorderSide.none),
|
||||
backgroundColor: WidgetStatePropertyAll(Color(0xFFEAEAEA)),
|
||||
),
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
),
|
||||
const SizedBox(height: 40),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return InkWell(
|
||||
onTap: widget.onPressed,
|
||||
onTap: _onTap,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
child: Ink(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
|
||||
@@ -48,6 +48,19 @@ class _GlossaryModerationRequestModalWidgetState extends State<GlossaryModeratio
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const Spacer(),
|
||||
IconButton(
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(),
|
||||
icon: const Icon(Icons.close, color: Color(0xFF323F4B)),
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
const Text(
|
||||
'Добавление нового продукта',
|
||||
textAlign: TextAlign.center,
|
||||
@@ -68,14 +81,15 @@ class _GlossaryModerationRequestModalWidgetState extends State<GlossaryModeratio
|
||||
onTap: () async {
|
||||
glossary = (await widget.diaryCubit.sendProductToModeration(
|
||||
name: productNameController.text,
|
||||
energy: 0,
|
||||
proteins: 0,
|
||||
fats: 0,
|
||||
carbohydrates: 0,
|
||||
energy: 1,
|
||||
proteins: 1,
|
||||
fats: 1,
|
||||
carbohydrates: 1,
|
||||
))?.glossary;
|
||||
setState(() {
|
||||
glossaryState = GlossaryState.success;
|
||||
});
|
||||
if (context.mounted) {
|
||||
// ignore: use_build_context_synchronously
|
||||
Navigator.of(context).pop(glossary);
|
||||
}
|
||||
},
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
child: Ink(
|
||||
@@ -126,6 +140,19 @@ class _GlossaryModerationRequestModalWidgetState extends State<GlossaryModeratio
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const Spacer(),
|
||||
IconButton(
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(),
|
||||
icon: const Icon(Icons.close, color: Color(0xFF323F4B)),
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
const Text(
|
||||
'Продукт успешно отправлен\nна модерацию',
|
||||
textAlign: TextAlign.center,
|
||||
@@ -163,6 +190,19 @@ class _GlossaryModerationRequestModalWidgetState extends State<GlossaryModeratio
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const Spacer(),
|
||||
IconButton(
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(),
|
||||
icon: const Icon(Icons.close, color: Color(0xFF323F4B)),
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
const Text(
|
||||
'Добавление нового продукта',
|
||||
textAlign: TextAlign.center,
|
||||
|
||||
@@ -21,7 +21,11 @@ class DiaryRepository {
|
||||
Future<DiaryDayModel> getDiaryByDate(String date) async {
|
||||
try {
|
||||
Set<int> ids = {};
|
||||
final DiaryDayModel diaryDay = (await getDiaryIndexByDate(date)).last;
|
||||
final diaryDays = await getDiaryIndexByDate(date);
|
||||
if (diaryDays.isEmpty) {
|
||||
throw Exception('No diary entry found for $date');
|
||||
}
|
||||
final DiaryDayModel diaryDay = diaryDays.last;
|
||||
for (var meal in diaryDay.meals) {
|
||||
for (var portion in meal.portions) {
|
||||
if (portion.productNutritionalValueId != null) {
|
||||
|
||||
@@ -106,15 +106,14 @@ class _AboutDietScreenState extends State<AboutDietScreen> {
|
||||
resizeToAvoidBottomInset: true,
|
||||
bottomSheet: Container(
|
||||
padding: EdgeInsets.only(
|
||||
top: 12,
|
||||
bottom: 12 + MediaQuery.of(context).padding.bottom + 56, // 56 - высота bottomNavigationBar
|
||||
bottom: MediaQuery.of(context).padding.bottom + 8,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
border: Border(top: BorderSide(color: Colors.black.withValues(alpha: 0.1))),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'dart:math' as math;
|
||||
import 'package:eatvio_app/generated/l10n.dart';
|
||||
import 'package:eatvio_app/core/theme/custom_text_style.dart';
|
||||
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
||||
import 'package:eatvio_app/core/widgets/markdown_html_widget.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_svg/svg.dart';
|
||||
|
||||
@@ -35,7 +36,9 @@ class _ExpandableTextState extends State<ExpandableText> {
|
||||
void _checkTextOverflow() {
|
||||
final textRenderObject = _textKey.currentContext?.findRenderObject();
|
||||
if (textRenderObject is RenderBox) {
|
||||
final maxLinesHeight = widget.maxLines * TextStyles.textMMediumAuto.fontSize! * 1.2;
|
||||
final fontSize = TextStyles.textMMediumAuto.fontSize ?? 16;
|
||||
final lineHeight = fontSize * 1.5;
|
||||
final maxLinesHeight = widget.maxLines * lineHeight;
|
||||
final actualHeight = textRenderObject.size.height;
|
||||
|
||||
if (actualHeight > maxLinesHeight && !showExpandButton) {
|
||||
@@ -55,6 +58,7 @@ class _ExpandableTextState extends State<ExpandableText> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final s = S.of(context);
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
@@ -65,12 +69,18 @@ class _ExpandableTextState extends State<ExpandableText> {
|
||||
curve: Curves.easeInOut,
|
||||
child: Stack(
|
||||
children: [
|
||||
Text(
|
||||
widget.text,
|
||||
key: _textKey,
|
||||
style: TextStyles.textMMediumAuto.copyWith(color: appTheme.black),
|
||||
maxLines: isExpanded ? null : widget.maxLines,
|
||||
overflow: TextOverflow.clip,
|
||||
ClipRect(
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
maxHeight: isExpanded ? double.infinity : (widget.maxLines * (TextStyles.textMMediumAuto.fontSize ?? 16) * 1.5),
|
||||
),
|
||||
child: MarkdownHtmlWidget(
|
||||
key: _textKey,
|
||||
markdownText: widget.text,
|
||||
fontSize: TextStyles.textMMediumAuto.fontSize ?? 16,
|
||||
textColor: appTheme.black,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (!isExpanded && showExpandButton)
|
||||
Positioned.fill(
|
||||
|
||||
@@ -11,6 +11,7 @@ import 'package:eatvio_app/features/create_plan/presentation/create_plan_screen.
|
||||
import 'package:eatvio_app/features/feed/presentation/bloc/consultants_cubit/consultants_cubit.dart';
|
||||
import 'package:eatvio_app/features/feed/presentation/bloc/diets_cubit/diets_cubit.dart';
|
||||
import 'package:eatvio_app/features/feed/presentation/bloc/feed_cubit/feed_cubit.dart';
|
||||
import 'package:eatvio_app/features/feed/presentation/bloc/feed_cubit/feed_state.dart';
|
||||
import 'package:eatvio_app/features/feed/presentation/bloc/profile_cubit/profile_cubit.dart';
|
||||
import 'package:eatvio_app/features/feed/presentation/bloc/profile_cubit/profile_state.dart';
|
||||
import 'package:eatvio_app/features/feed/presentation/bloc/recipes_cubit/recipes_cubit.dart';
|
||||
@@ -74,6 +75,16 @@ class _FeedScreenContentState extends State<FeedScreenContent> {
|
||||
chipsLabelList = hidePlans ? ['Все', 'Рецепты', 'Диеты', 'Консультанты'] : ['Все', 'Рецепты', 'Диеты', 'Планы питания', 'Консультанты'];
|
||||
consultantsScrollController.addListener(_onScrollConsultants);
|
||||
_checkRegistrationDate(context);
|
||||
// Инициализация глобальных кубитов при первом открытии экрана
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
final feedCubit = context.read<FeedCubit>();
|
||||
final state = feedCubit.state;
|
||||
// Загружаем данные, если они еще не загружены
|
||||
if (state is! FeedStateLoaded) {
|
||||
feedCubit.loadFeedBlogPostList();
|
||||
}
|
||||
});
|
||||
super.initState();
|
||||
}
|
||||
|
||||
@@ -217,7 +228,9 @@ class _FeedScreenContentState extends State<FeedScreenContent> {
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
switch (_selectedChipIndex) {
|
||||
1 => AddItemButton(onTap: () => GoRouter.of(context).pushNamed(RecipeCreateScreen.name)),
|
||||
1 => AddItemButton(
|
||||
onTap: state.profile?.roles.contains('consultant') ?? false ? () => GoRouter.of(context).pushNamed(RecipeCreateScreen.name) : null,
|
||||
),
|
||||
2 => AddItemButton(
|
||||
onTap: state.profile?.roles.contains('consultant') ?? false
|
||||
? () async {
|
||||
|
||||
@@ -7,17 +7,16 @@ import 'package:eatvio_app/core/theme/theme_helper.dart';
|
||||
import 'package:eatvio_app/core/utils/date_time_utils.dart';
|
||||
import 'package:eatvio_app/core/widgets/custom_popup.dart';
|
||||
import 'package:eatvio_app/core/widgets/feed_icon_button.dart';
|
||||
import 'package:eatvio_app/core/widgets/markdown_html_widget.dart';
|
||||
import 'package:eatvio_app/features/chat/presentation/pages/photo_view_screen.dart';
|
||||
import 'package:eatvio_app/features/feed/presentation/bloc/feed_cubit/feed_cubit.dart';
|
||||
import 'package:eatvio_app/features/feed/presentation/bloc/feed_cubit/feed_state.dart';
|
||||
import 'package:eatvio_app/features/feed/presentation/widgets/subscribe_button_widget.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_html/flutter_html.dart';
|
||||
import 'package:flutter_svg/flutter_svg.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:share_plus/share_plus.dart';
|
||||
import 'package:markdown/markdown.dart' as md;
|
||||
|
||||
class BlogPostWidget extends StatefulWidget {
|
||||
final int index;
|
||||
@@ -125,37 +124,46 @@ class _BlogPostWidgetState extends State<BlogPostWidget> {
|
||||
if (state.feedBlogPostList[widget.index].description.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
child: Html(
|
||||
data: md.markdownToHtml(
|
||||
(state.feedBlogPostList[widget.index].description.length > 200
|
||||
? state.feedBlogPostList[widget.index].description.substring(0, 200).replaceAll('\n', '<br>')
|
||||
: state.feedBlogPostList[widget.index].description.replaceAll('\n', '<br>')),
|
||||
),
|
||||
style: {
|
||||
"body": Style(margin: Margins.zero, padding: HtmlPaddings.zero, fontSize: FontSize(16), lineHeight: const LineHeight(1.5)),
|
||||
"p": Style(margin: Margins.only(bottom: 12), color: Colors.black87),
|
||||
"h1, h2, h3, h4, h5, h6": Style(margin: Margins.only(top: 16, bottom: 8), color: Colors.black, fontWeight: FontWeight.bold),
|
||||
"h1": Style(fontSize: FontSize(24)),
|
||||
"h2": Style(fontSize: FontSize(20)),
|
||||
"h3": Style(fontSize: FontSize(18)),
|
||||
"ul, ol": Style(margin: Margins.only(bottom: 12), padding: HtmlPaddings.only(left: 16)),
|
||||
"li": Style(margin: Margins.only(bottom: 4), color: Colors.black87),
|
||||
"strong, b": Style(fontWeight: FontWeight.bold, color: Colors.black),
|
||||
"em, i": Style(fontStyle: FontStyle.italic),
|
||||
"code": Style(
|
||||
backgroundColor: Colors.grey.shade100,
|
||||
padding: HtmlPaddings.symmetric(horizontal: 4, vertical: 2),
|
||||
fontFamily: 'monospace',
|
||||
),
|
||||
"pre": Style(backgroundColor: Colors.grey.shade100, padding: HtmlPaddings.all(12), margin: Margins.only(bottom: 12)),
|
||||
"blockquote": Style(
|
||||
padding: HtmlPaddings.only(left: 16),
|
||||
margin: Margins.only(left: 8, bottom: 12),
|
||||
fontStyle: FontStyle.italic,
|
||||
color: Colors.grey.shade700,
|
||||
),
|
||||
"a": Style(color: Colors.blue, textDecoration: TextDecoration.underline),
|
||||
"img": Style(width: Width(MediaQuery.of(context).size.width - 32)),
|
||||
child: Builder(
|
||||
builder: (context) {
|
||||
String description = state.feedBlogPostList[widget.index].description;
|
||||
final isLong = description.length > 200;
|
||||
|
||||
if (isLong) {
|
||||
// Умная обрезка: обрезаем markdown текст, не ломая синтаксис
|
||||
int cutPoint = 200;
|
||||
|
||||
// Проверяем, есть ли незакрытые markdown теги
|
||||
String preview = description.substring(0, cutPoint);
|
||||
|
||||
// Считаем открывающие и закрывающие теги
|
||||
int boldOpen = (preview.split('**').length - 1) ~/ 2;
|
||||
int boldClose = preview.split('**').length - 1 - boldOpen * 2;
|
||||
bool hasUnclosedBold = boldOpen > boldClose;
|
||||
|
||||
// Если есть незакрытый bold, ищем закрытие
|
||||
if (hasUnclosedBold) {
|
||||
int searchEnd = (cutPoint + 100).clamp(0, description.length);
|
||||
int boldClosePos = description.indexOf('**', cutPoint);
|
||||
if (boldClosePos != -1 && boldClosePos < searchEnd) {
|
||||
cutPoint = boldClosePos + 2;
|
||||
} else {
|
||||
// Если не нашли, обрезаем до последнего пробела
|
||||
cutPoint = description.lastIndexOf(' ', 200);
|
||||
if (cutPoint == -1 || cutPoint < 150) cutPoint = 200;
|
||||
}
|
||||
} else {
|
||||
// Обрезаем до последнего пробела для красоты
|
||||
cutPoint = description.lastIndexOf(' ', cutPoint);
|
||||
if (cutPoint == -1 || cutPoint < 150) cutPoint = 200;
|
||||
}
|
||||
|
||||
description = description.substring(0, cutPoint);
|
||||
}
|
||||
|
||||
return MarkdownHtmlWidget(
|
||||
markdownText: description,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
@@ -13,12 +13,10 @@ import 'package:eatvio_app/core/theme/custom_text_style.dart';
|
||||
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_html/flutter_html.dart';
|
||||
import 'package:eatvio_app/core/widgets/markdown_html_widget.dart';
|
||||
import 'package:flutter_svg/flutter_svg.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:share_plus/share_plus.dart';
|
||||
import 'package:markdown/markdown.dart' as md;
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
class DietsWidget extends StatefulWidget {
|
||||
final Diet diet;
|
||||
@@ -117,86 +115,11 @@ class _DietsWidgetState extends State<DietsWidget> {
|
||||
String details = widget.diet.details.length > 200
|
||||
? '${widget.diet.details.substring(0, 200)}...'
|
||||
: widget.diet.details;
|
||||
// Автоматически преобразуем URL в markdown ссылки
|
||||
// Сначала исправляем неправильный синтаксис markdown ссылок
|
||||
final wrongLinkRegex = RegExp(r'\[(https?://[^\]]+|www\.[^\]]+)\]\(([^\)]+)\)');
|
||||
details = details.replaceAllMapped(wrongLinkRegex, (match) {
|
||||
final urlInText = match.group(1)!;
|
||||
if (!urlInText.startsWith('http://') && !urlInText.startsWith('https://') && !urlInText.startsWith('www.')) {
|
||||
return match.group(0)!;
|
||||
}
|
||||
final fullUrl = urlInText.startsWith('www.') ? 'https://$urlInText' : urlInText;
|
||||
return '[$urlInText]($fullUrl)';
|
||||
});
|
||||
|
||||
final linkRegex = RegExp(r'\[([^\]]+)\]\(([^\)]+)\)');
|
||||
final existingLinks = <_LinkRange>[];
|
||||
for (final linkMatch in linkRegex.allMatches(details)) {
|
||||
existingLinks.add(_LinkRange(linkMatch.start, linkMatch.end));
|
||||
}
|
||||
final urlRegex = RegExp(r'(https?://[^\s\)]+|www\.[^\s\)]+)', caseSensitive: false);
|
||||
String result = details;
|
||||
int offset = 0;
|
||||
for (final urlMatch in urlRegex.allMatches(details)) {
|
||||
bool isInsideLink = existingLinks.any((link) =>
|
||||
urlMatch.start >= link.start && urlMatch.end <= link.end);
|
||||
if (!isInsideLink) {
|
||||
final url = urlMatch.group(0)!;
|
||||
final fullUrl = url.startsWith('www.') ? 'https://$url' : url;
|
||||
final replacement = '[$url]($fullUrl)';
|
||||
result = result.replaceRange(
|
||||
urlMatch.start + offset,
|
||||
urlMatch.end + offset,
|
||||
replacement,
|
||||
);
|
||||
offset += replacement.length - url.length;
|
||||
}
|
||||
}
|
||||
details = result;
|
||||
return Html(
|
||||
data: md.markdownToHtml(
|
||||
details.replaceAll('\n', '<br>'),
|
||||
extensionSet: md.ExtensionSet.commonMark,
|
||||
),
|
||||
style: {
|
||||
"body": Style(
|
||||
margin: Margins.zero,
|
||||
padding: HtmlPaddings.zero,
|
||||
fontSize: FontSize(TextStyles.textSMedium.fontSize ?? 14),
|
||||
lineHeight: const LineHeight(1.5),
|
||||
),
|
||||
"p": Style(margin: Margins.only(bottom: 12), color: appTheme.black),
|
||||
"h1, h2, h3, h4, h5, h6": Style(margin: Margins.only(top: 16, bottom: 8), color: appTheme.black, fontWeight: FontWeight.bold),
|
||||
"h1": Style(fontSize: FontSize(24)),
|
||||
"h2": Style(fontSize: FontSize(20)),
|
||||
"h3": Style(fontSize: FontSize(18)),
|
||||
"ul, ol": Style(margin: Margins.only(bottom: 12), padding: HtmlPaddings.only(left: 16)),
|
||||
"li": Style(margin: Margins.only(bottom: 4), color: appTheme.black),
|
||||
"strong, b": Style(fontWeight: FontWeight.bold, color: appTheme.black),
|
||||
"em, i": Style(fontStyle: FontStyle.italic),
|
||||
"code": Style(
|
||||
backgroundColor: Colors.grey.shade100,
|
||||
padding: HtmlPaddings.symmetric(horizontal: 4, vertical: 2),
|
||||
fontFamily: 'monospace',
|
||||
),
|
||||
"pre": Style(backgroundColor: Colors.grey.shade100, padding: HtmlPaddings.all(12), margin: Margins.only(bottom: 12)),
|
||||
"blockquote": Style(
|
||||
padding: HtmlPaddings.only(left: 16),
|
||||
margin: Margins.only(left: 8, bottom: 12),
|
||||
fontStyle: FontStyle.italic,
|
||||
color: Colors.grey.shade700,
|
||||
),
|
||||
"a": Style(color: Colors.blue, textDecoration: TextDecoration.underline),
|
||||
"img": Style(width: Width(MediaQuery.of(context).size.width - 32)),
|
||||
},
|
||||
onLinkTap: (url, attributes, element) async {
|
||||
if (url != null) {
|
||||
final uri = Uri.parse(url);
|
||||
if (await canLaunchUrl(uri)) {
|
||||
await launchUrl(uri, mode: LaunchMode.externalApplication);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
return MarkdownHtmlWidget(
|
||||
markdownText: details,
|
||||
fontSize: TextStyles.textSMedium.fontSize ?? 14,
|
||||
textColor: appTheme.black,
|
||||
);
|
||||
},
|
||||
),
|
||||
@@ -267,8 +190,3 @@ class _DietsWidgetState extends State<DietsWidget> {
|
||||
}
|
||||
}
|
||||
|
||||
class _LinkRange {
|
||||
final int start;
|
||||
final int end;
|
||||
_LinkRange(this.start, this.end);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import 'package:eatvio_app/core/widgets/bookmark_button.dart';
|
||||
import 'package:eatvio_app/core/widgets/custom_popup.dart';
|
||||
import 'package:eatvio_app/core/widgets/info_row.dart';
|
||||
import 'package:eatvio_app/core/widgets/feed_icon_button.dart';
|
||||
import 'package:eatvio_app/core/widgets/markdown_html_widget.dart';
|
||||
import 'package:eatvio_app/features/diets/presentation/widgets/info_bouble.dart';
|
||||
import 'package:eatvio_app/features/feed/models/plan_pagination_model.dart';
|
||||
import 'package:eatvio_app/core/theme/custom_text_style.dart';
|
||||
@@ -15,12 +16,9 @@ import 'package:eatvio_app/features/feed/presentation/bloc/plans_cubit/plans_cub
|
||||
import 'package:eatvio_app/generated/l10n.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_html/flutter_html.dart';
|
||||
import 'package:flutter_svg/flutter_svg.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:share_plus/share_plus.dart';
|
||||
import 'package:markdown/markdown.dart' as md;
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
class PlanWidget extends StatefulWidget {
|
||||
final PlanModel plan;
|
||||
@@ -117,88 +115,11 @@ class _PlanWidgetState extends State<PlanWidget> {
|
||||
const SizedBox(height: 12),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Builder(
|
||||
builder: (context) {
|
||||
String details = widget.plan.details.length > 200
|
||||
? '${widget.plan.details.substring(0, 200)}...'
|
||||
: widget.plan.details;
|
||||
// Автоматически преобразуем URL в markdown ссылки
|
||||
// Сначала исправляем неправильный синтаксис markdown ссылок
|
||||
final wrongLinkRegex = RegExp(r'\[(https?://[^\]]+|www\.[^\]]+)\]\(([^\)]+)\)');
|
||||
details = details.replaceAllMapped(wrongLinkRegex, (match) {
|
||||
final urlInText = match.group(1)!;
|
||||
if (!urlInText.startsWith('http://') && !urlInText.startsWith('https://') && !urlInText.startsWith('www.')) {
|
||||
return match.group(0)!;
|
||||
}
|
||||
final fullUrl = urlInText.startsWith('www.') ? 'https://$urlInText' : urlInText;
|
||||
return '[$urlInText]($fullUrl)';
|
||||
});
|
||||
|
||||
final linkRegex = RegExp(r'\[([^\]]+)\]\(([^\)]+)\)');
|
||||
final existingLinks = <_LinkRange>[];
|
||||
for (final linkMatch in linkRegex.allMatches(details)) {
|
||||
existingLinks.add(_LinkRange(linkMatch.start, linkMatch.end));
|
||||
}
|
||||
final urlRegex = RegExp(r'(https?://[^\s\)]+|www\.[^\s\)]+)', caseSensitive: false);
|
||||
String result = details;
|
||||
int offset = 0;
|
||||
for (final urlMatch in urlRegex.allMatches(details)) {
|
||||
bool isInsideLink = existingLinks.any((link) =>
|
||||
urlMatch.start >= link.start && urlMatch.end <= link.end);
|
||||
if (!isInsideLink) {
|
||||
final url = urlMatch.group(0)!;
|
||||
final fullUrl = url.startsWith('www.') ? 'https://$url' : url;
|
||||
final replacement = '[$url]($fullUrl)';
|
||||
result = result.replaceRange(
|
||||
urlMatch.start + offset,
|
||||
urlMatch.end + offset,
|
||||
replacement,
|
||||
);
|
||||
offset += replacement.length - url.length;
|
||||
}
|
||||
}
|
||||
details = result;
|
||||
return Html(
|
||||
data: md.markdownToHtml(
|
||||
details.replaceAll('\n', '<br>'),
|
||||
extensionSet: md.ExtensionSet.commonMark,
|
||||
),
|
||||
style: {
|
||||
"body": Style(margin: Margins.zero, padding: HtmlPaddings.zero, fontSize: FontSize(16), lineHeight: const LineHeight(1.5)),
|
||||
"p": Style(margin: Margins.only(bottom: 12), color: appTheme.black),
|
||||
"h1, h2, h3, h4, h5, h6": Style(margin: Margins.only(top: 16, bottom: 8), color: appTheme.black, fontWeight: FontWeight.bold),
|
||||
"h1": Style(fontSize: FontSize(24)),
|
||||
"h2": Style(fontSize: FontSize(20)),
|
||||
"h3": Style(fontSize: FontSize(18)),
|
||||
"ul, ol": Style(margin: Margins.only(bottom: 12), padding: HtmlPaddings.only(left: 16)),
|
||||
"li": Style(margin: Margins.only(bottom: 4), color: appTheme.black),
|
||||
"strong, b": Style(fontWeight: FontWeight.bold, color: appTheme.black),
|
||||
"em, i": Style(fontStyle: FontStyle.italic),
|
||||
"code": Style(
|
||||
backgroundColor: Colors.grey.shade100,
|
||||
padding: HtmlPaddings.symmetric(horizontal: 4, vertical: 2),
|
||||
fontFamily: 'monospace',
|
||||
),
|
||||
"pre": Style(backgroundColor: Colors.grey.shade100, padding: HtmlPaddings.all(12), margin: Margins.only(bottom: 12)),
|
||||
"blockquote": Style(
|
||||
padding: HtmlPaddings.only(left: 16),
|
||||
margin: Margins.only(left: 8, bottom: 12),
|
||||
fontStyle: FontStyle.italic,
|
||||
color: Colors.grey.shade700,
|
||||
),
|
||||
"a": Style(color: Colors.blue, textDecoration: TextDecoration.underline),
|
||||
"img": Style(width: Width(MediaQuery.of(context).size.width - 32)),
|
||||
},
|
||||
onLinkTap: (url, attributes, element) async {
|
||||
if (url != null) {
|
||||
final uri = Uri.parse(url);
|
||||
if (await canLaunchUrl(uri)) {
|
||||
await launchUrl(uri, mode: LaunchMode.externalApplication);
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
},
|
||||
child: MarkdownHtmlWidget(
|
||||
markdownText: widget.plan.details.length > 200
|
||||
? '${widget.plan.details.substring(0, 200)}...'
|
||||
: widget.plan.details,
|
||||
textColor: appTheme.black,
|
||||
),
|
||||
),
|
||||
if (widget.plan.details.length > 200)
|
||||
@@ -312,9 +233,3 @@ class _PlanWidgetState extends State<PlanWidget> {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _LinkRange {
|
||||
final int start;
|
||||
final int end;
|
||||
_LinkRange(this.start, this.end);
|
||||
}
|
||||
|
||||
@@ -16,12 +16,10 @@ import 'package:eatvio_app/features/feed/presentation/bloc/recipes_cubit/recipes
|
||||
import 'package:eatvio_app/generated/l10n.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_html/flutter_html.dart';
|
||||
import 'package:eatvio_app/core/widgets/markdown_html_widget.dart';
|
||||
import 'package:flutter_svg/flutter_svg.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:share_plus/share_plus.dart';
|
||||
import 'package:markdown/markdown.dart' as md;
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
class RecipesWidget extends StatefulWidget {
|
||||
final int index;
|
||||
@@ -155,88 +153,11 @@ class _RecipesWidgetState extends State<RecipesWidget> {
|
||||
String description = widget.recipe.description.length > 200
|
||||
? widget.recipe.description.substring(0, 200)
|
||||
: widget.recipe.description;
|
||||
// Автоматически преобразуем URL в markdown ссылки
|
||||
// Сначала исправляем неправильный синтаксис markdown ссылок
|
||||
final wrongLinkRegex = RegExp(r'\[(https?://[^\]]+|www\.[^\]]+)\]\(([^\)]+)\)');
|
||||
description = description.replaceAllMapped(wrongLinkRegex, (match) {
|
||||
final urlInText = match.group(1)!;
|
||||
if (!urlInText.startsWith('http://') && !urlInText.startsWith('https://') && !urlInText.startsWith('www.')) {
|
||||
return match.group(0)!;
|
||||
}
|
||||
final fullUrl = urlInText.startsWith('www.') ? 'https://$urlInText' : urlInText;
|
||||
return '[$urlInText]($fullUrl)';
|
||||
});
|
||||
|
||||
// Находим все существующие markdown ссылки и их позиции
|
||||
final linkRegex = RegExp(r'\[([^\]]+)\]\(([^\)]+)\)');
|
||||
final existingLinks = <_LinkRange>[];
|
||||
for (final linkMatch in linkRegex.allMatches(description)) {
|
||||
existingLinks.add(_LinkRange(linkMatch.start, linkMatch.end));
|
||||
}
|
||||
|
||||
final urlRegex = RegExp(r'(https?://[^\s\)]+|www\.[^\s\)]+)', caseSensitive: false);
|
||||
String result = description;
|
||||
int offset = 0;
|
||||
for (final urlMatch in urlRegex.allMatches(description)) {
|
||||
bool isInsideLink = existingLinks.any((link) =>
|
||||
urlMatch.start >= link.start && urlMatch.end <= link.end);
|
||||
if (!isInsideLink) {
|
||||
final url = urlMatch.group(0)!;
|
||||
final fullUrl = url.startsWith('www.') ? 'https://$url' : url;
|
||||
final replacement = '[$url]($fullUrl)';
|
||||
result = result.replaceRange(
|
||||
urlMatch.start + offset,
|
||||
urlMatch.end + offset,
|
||||
replacement,
|
||||
);
|
||||
offset += replacement.length - url.length;
|
||||
}
|
||||
}
|
||||
description = result;
|
||||
return Html(
|
||||
data: md.markdownToHtml(
|
||||
description.replaceAll('\n', '<br>'),
|
||||
extensionSet: md.ExtensionSet.commonMark,
|
||||
),
|
||||
style: {
|
||||
"body": Style(
|
||||
margin: Margins.zero,
|
||||
padding: HtmlPaddings.zero,
|
||||
fontSize: FontSize(TextStyles.textSMedium.fontSize ?? 14),
|
||||
lineHeight: const LineHeight(1.5),
|
||||
),
|
||||
"p": Style(margin: Margins.only(bottom: 12), color: appTheme.black),
|
||||
"h1, h2, h3, h4, h5, h6": Style(margin: Margins.only(top: 16, bottom: 8), color: appTheme.black, fontWeight: FontWeight.bold),
|
||||
"h1": Style(fontSize: FontSize(24)),
|
||||
"h2": Style(fontSize: FontSize(20)),
|
||||
"h3": Style(fontSize: FontSize(18)),
|
||||
"ul, ol": Style(margin: Margins.only(bottom: 12), padding: HtmlPaddings.only(left: 16)),
|
||||
"li": Style(margin: Margins.only(bottom: 4), color: appTheme.black),
|
||||
"strong, b": Style(fontWeight: FontWeight.bold, color: appTheme.black),
|
||||
"em, i": Style(fontStyle: FontStyle.italic),
|
||||
"code": Style(
|
||||
backgroundColor: Colors.grey.shade100,
|
||||
padding: HtmlPaddings.symmetric(horizontal: 4, vertical: 2),
|
||||
fontFamily: 'monospace',
|
||||
),
|
||||
"pre": Style(backgroundColor: Colors.grey.shade100, padding: HtmlPaddings.all(12), margin: Margins.only(bottom: 12)),
|
||||
"blockquote": Style(
|
||||
padding: HtmlPaddings.only(left: 16),
|
||||
margin: Margins.only(left: 8, bottom: 12),
|
||||
fontStyle: FontStyle.italic,
|
||||
color: Colors.grey.shade700,
|
||||
),
|
||||
"a": Style(color: Colors.blue, textDecoration: TextDecoration.underline),
|
||||
"img": Style(width: Width(MediaQuery.of(context).size.width - 32)),
|
||||
},
|
||||
onLinkTap: (url, attributes, element) async {
|
||||
if (url != null) {
|
||||
final uri = Uri.parse(url);
|
||||
if (await canLaunchUrl(uri)) {
|
||||
await launchUrl(uri, mode: LaunchMode.externalApplication);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
return MarkdownHtmlWidget(
|
||||
markdownText: description,
|
||||
fontSize: TextStyles.textSMedium.fontSize ?? 14,
|
||||
textColor: appTheme.black,
|
||||
);
|
||||
},
|
||||
),
|
||||
@@ -330,8 +251,3 @@ class _RecipesWidgetState extends State<RecipesWidget> {
|
||||
}
|
||||
}
|
||||
|
||||
class _LinkRange {
|
||||
final int start;
|
||||
final int end;
|
||||
_LinkRange(this.start, this.end);
|
||||
}
|
||||
|
||||
@@ -21,12 +21,10 @@ import 'package:eatvio_app/features/personal_diet_suggestion/presentation/person
|
||||
import 'package:eatvio_app/generated/l10n.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_html/flutter_html.dart';
|
||||
import 'package:eatvio_app/core/widgets/markdown_html_widget.dart';
|
||||
import 'package:flutter_svg/svg.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:share_plus/share_plus.dart';
|
||||
import 'package:markdown/markdown.dart' as md;
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
class MyDietWidget extends StatefulWidget {
|
||||
final Diet diet;
|
||||
@@ -86,86 +84,11 @@ class _MyDietWidgetState extends State<MyDietWidget> {
|
||||
String details = widget.diet.details.length > 200
|
||||
? '${widget.diet.details.substring(0, 200)}...'
|
||||
: widget.diet.details;
|
||||
// Автоматически преобразуем URL в markdown ссылки
|
||||
// Сначала исправляем неправильный синтаксис markdown ссылок
|
||||
final wrongLinkRegex = RegExp(r'\[(https?://[^\]]+|www\.[^\]]+)\]\(([^\)]+)\)');
|
||||
details = details.replaceAllMapped(wrongLinkRegex, (match) {
|
||||
final urlInText = match.group(1)!;
|
||||
if (!urlInText.startsWith('http://') && !urlInText.startsWith('https://') && !urlInText.startsWith('www.')) {
|
||||
return match.group(0)!;
|
||||
}
|
||||
final fullUrl = urlInText.startsWith('www.') ? 'https://$urlInText' : urlInText;
|
||||
return '[$urlInText]($fullUrl)';
|
||||
});
|
||||
|
||||
final linkRegex = RegExp(r'\[([^\]]+)\]\(([^\)]+)\)');
|
||||
final existingLinks = <_LinkRange>[];
|
||||
for (final linkMatch in linkRegex.allMatches(details)) {
|
||||
existingLinks.add(_LinkRange(linkMatch.start, linkMatch.end));
|
||||
}
|
||||
final urlRegex = RegExp(r'(https?://[^\s\)]+|www\.[^\s\)]+)', caseSensitive: false);
|
||||
String result = details;
|
||||
int offset = 0;
|
||||
for (final urlMatch in urlRegex.allMatches(details)) {
|
||||
bool isInsideLink = existingLinks.any((link) =>
|
||||
urlMatch.start >= link.start && urlMatch.end <= link.end);
|
||||
if (!isInsideLink) {
|
||||
final url = urlMatch.group(0)!;
|
||||
final fullUrl = url.startsWith('www.') ? 'https://$url' : url;
|
||||
final replacement = '[$url]($fullUrl)';
|
||||
result = result.replaceRange(
|
||||
urlMatch.start + offset,
|
||||
urlMatch.end + offset,
|
||||
replacement,
|
||||
);
|
||||
offset += replacement.length - url.length;
|
||||
}
|
||||
}
|
||||
details = result;
|
||||
return Html(
|
||||
data: md.markdownToHtml(
|
||||
details.replaceAll('\n', '<br>'),
|
||||
extensionSet: md.ExtensionSet.commonMark,
|
||||
),
|
||||
style: {
|
||||
"body": Style(
|
||||
margin: Margins.zero,
|
||||
padding: HtmlPaddings.zero,
|
||||
fontSize: FontSize(TextStyles.textSMedium.fontSize ?? 14),
|
||||
lineHeight: const LineHeight(1.5),
|
||||
),
|
||||
"p": Style(margin: Margins.only(bottom: 12), color: appTheme.black),
|
||||
"h1, h2, h3, h4, h5, h6": Style(margin: Margins.only(top: 16, bottom: 8), color: appTheme.black, fontWeight: FontWeight.bold),
|
||||
"h1": Style(fontSize: FontSize(24)),
|
||||
"h2": Style(fontSize: FontSize(20)),
|
||||
"h3": Style(fontSize: FontSize(18)),
|
||||
"ul, ol": Style(margin: Margins.only(bottom: 12), padding: HtmlPaddings.only(left: 16)),
|
||||
"li": Style(margin: Margins.only(bottom: 4), color: appTheme.black),
|
||||
"strong, b": Style(fontWeight: FontWeight.bold, color: appTheme.black),
|
||||
"em, i": Style(fontStyle: FontStyle.italic),
|
||||
"code": Style(
|
||||
backgroundColor: Colors.grey.shade100,
|
||||
padding: HtmlPaddings.symmetric(horizontal: 4, vertical: 2),
|
||||
fontFamily: 'monospace',
|
||||
),
|
||||
"pre": Style(backgroundColor: Colors.grey.shade100, padding: HtmlPaddings.all(12), margin: Margins.only(bottom: 12)),
|
||||
"blockquote": Style(
|
||||
padding: HtmlPaddings.only(left: 16),
|
||||
margin: Margins.only(left: 8, bottom: 12),
|
||||
fontStyle: FontStyle.italic,
|
||||
color: Colors.grey.shade700,
|
||||
),
|
||||
"a": Style(color: Colors.blue, textDecoration: TextDecoration.underline),
|
||||
"img": Style(width: Width(MediaQuery.of(context).size.width - 32)),
|
||||
},
|
||||
onLinkTap: (url, attributes, element) async {
|
||||
if (url != null) {
|
||||
final uri = Uri.parse(url);
|
||||
if (await canLaunchUrl(uri)) {
|
||||
await launchUrl(uri, mode: LaunchMode.externalApplication);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
return MarkdownHtmlWidget(
|
||||
markdownText: details,
|
||||
fontSize: TextStyles.textSMedium.fontSize ?? 14,
|
||||
textColor: appTheme.black,
|
||||
);
|
||||
},
|
||||
),
|
||||
@@ -323,8 +246,3 @@ class _MyDietWidgetState extends State<MyDietWidget> {
|
||||
}
|
||||
}
|
||||
|
||||
class _LinkRange {
|
||||
final int start;
|
||||
final int end;
|
||||
_LinkRange(this.start, this.end);
|
||||
}
|
||||
|
||||
@@ -14,12 +14,10 @@ import 'package:eatvio_app/features/my_plans/presentation/cubit/my_plans_state.d
|
||||
import 'package:eatvio_app/generated/l10n.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_html/flutter_html.dart';
|
||||
import 'package:eatvio_app/core/widgets/markdown_html_widget.dart';
|
||||
// Removed SVG icon usage after switching to text buttons
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:share_plus/share_plus.dart';
|
||||
import 'package:markdown/markdown.dart' as md;
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
class MyPlanWidget extends StatefulWidget {
|
||||
final PlanModel plan;
|
||||
@@ -78,86 +76,11 @@ class _MyPlanWidgetState extends State<MyPlanWidget> {
|
||||
String details = widget.plan.details.length > 200
|
||||
? '${widget.plan.details.substring(0, 200)}...'
|
||||
: widget.plan.details;
|
||||
// Автоматически преобразуем URL в markdown ссылки
|
||||
// Сначала исправляем неправильный синтаксис markdown ссылок
|
||||
final wrongLinkRegex = RegExp(r'\[(https?://[^\]]+|www\.[^\]]+)\]\(([^\)]+)\)');
|
||||
details = details.replaceAllMapped(wrongLinkRegex, (match) {
|
||||
final urlInText = match.group(1)!;
|
||||
if (!urlInText.startsWith('http://') && !urlInText.startsWith('https://') && !urlInText.startsWith('www.')) {
|
||||
return match.group(0)!;
|
||||
}
|
||||
final fullUrl = urlInText.startsWith('www.') ? 'https://$urlInText' : urlInText;
|
||||
return '[$urlInText]($fullUrl)';
|
||||
});
|
||||
|
||||
final linkRegex = RegExp(r'\[([^\]]+)\]\(([^\)]+)\)');
|
||||
final existingLinks = <_LinkRange>[];
|
||||
for (final linkMatch in linkRegex.allMatches(details)) {
|
||||
existingLinks.add(_LinkRange(linkMatch.start, linkMatch.end));
|
||||
}
|
||||
final urlRegex = RegExp(r'(https?://[^\s\)]+|www\.[^\s\)]+)', caseSensitive: false);
|
||||
String result = details;
|
||||
int offset = 0;
|
||||
for (final urlMatch in urlRegex.allMatches(details)) {
|
||||
bool isInsideLink = existingLinks.any((link) =>
|
||||
urlMatch.start >= link.start && urlMatch.end <= link.end);
|
||||
if (!isInsideLink) {
|
||||
final url = urlMatch.group(0)!;
|
||||
final fullUrl = url.startsWith('www.') ? 'https://$url' : url;
|
||||
final replacement = '[$url]($fullUrl)';
|
||||
result = result.replaceRange(
|
||||
urlMatch.start + offset,
|
||||
urlMatch.end + offset,
|
||||
replacement,
|
||||
);
|
||||
offset += replacement.length - url.length;
|
||||
}
|
||||
}
|
||||
details = result;
|
||||
return Html(
|
||||
data: md.markdownToHtml(
|
||||
details.replaceAll('\n', '<br>'),
|
||||
extensionSet: md.ExtensionSet.commonMark,
|
||||
),
|
||||
style: {
|
||||
"body": Style(
|
||||
margin: Margins.zero,
|
||||
padding: HtmlPaddings.zero,
|
||||
fontSize: FontSize(TextStyles.textSMedium.fontSize ?? 14),
|
||||
lineHeight: const LineHeight(1.5),
|
||||
),
|
||||
"p": Style(margin: Margins.only(bottom: 12), color: appTheme.black),
|
||||
"h1, h2, h3, h4, h5, h6": Style(margin: Margins.only(top: 16, bottom: 8), color: appTheme.black, fontWeight: FontWeight.bold),
|
||||
"h1": Style(fontSize: FontSize(24)),
|
||||
"h2": Style(fontSize: FontSize(20)),
|
||||
"h3": Style(fontSize: FontSize(18)),
|
||||
"ul, ol": Style(margin: Margins.only(bottom: 12), padding: HtmlPaddings.only(left: 16)),
|
||||
"li": Style(margin: Margins.only(bottom: 4), color: appTheme.black),
|
||||
"strong, b": Style(fontWeight: FontWeight.bold, color: appTheme.black),
|
||||
"em, i": Style(fontStyle: FontStyle.italic),
|
||||
"code": Style(
|
||||
backgroundColor: Colors.grey.shade100,
|
||||
padding: HtmlPaddings.symmetric(horizontal: 4, vertical: 2),
|
||||
fontFamily: 'monospace',
|
||||
),
|
||||
"pre": Style(backgroundColor: Colors.grey.shade100, padding: HtmlPaddings.all(12), margin: Margins.only(bottom: 12)),
|
||||
"blockquote": Style(
|
||||
padding: HtmlPaddings.only(left: 16),
|
||||
margin: Margins.only(left: 8, bottom: 12),
|
||||
fontStyle: FontStyle.italic,
|
||||
color: Colors.grey.shade700,
|
||||
),
|
||||
"a": Style(color: Colors.blue, textDecoration: TextDecoration.underline),
|
||||
"img": Style(width: Width(MediaQuery.of(context).size.width - 32)),
|
||||
},
|
||||
onLinkTap: (url, attributes, element) async {
|
||||
if (url != null) {
|
||||
final uri = Uri.parse(url);
|
||||
if (await canLaunchUrl(uri)) {
|
||||
await launchUrl(uri, mode: LaunchMode.externalApplication);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
return MarkdownHtmlWidget(
|
||||
markdownText: details,
|
||||
fontSize: TextStyles.textSMedium.fontSize ?? 14,
|
||||
textColor: appTheme.black,
|
||||
);
|
||||
},
|
||||
),
|
||||
@@ -268,8 +191,3 @@ class _MyPlanWidgetState extends State<MyPlanWidget> {
|
||||
}
|
||||
}
|
||||
|
||||
class _LinkRange {
|
||||
final int start;
|
||||
final int end;
|
||||
_LinkRange(this.start, this.end);
|
||||
}
|
||||
|
||||
@@ -24,6 +24,14 @@ class _NotificationsScreenState extends State<NotificationsScreen> {
|
||||
void initState() {
|
||||
super.initState();
|
||||
_scrollController.addListener(_onScroll);
|
||||
// Инициализация NotificationsCubit при первом открытии экрана
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
final cubit = context.read<NotificationsCubit>();
|
||||
final state = cubit.state;
|
||||
if (state is! NotificationsStateLoaded && state is! NotificationsStateError) {
|
||||
cubit.getNotifications();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -108,7 +116,7 @@ class _NotificationsScreenState extends State<NotificationsScreen> {
|
||||
),
|
||||
],
|
||||
)
|
||||
: const Placeholder(),
|
||||
: const SizedBox(),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -153,16 +153,13 @@ class _AboutPlanScreenState extends State<AboutPlanScreen> {
|
||||
backgroundColor: appTheme.gray100,
|
||||
resizeToAvoidBottomInset: true,
|
||||
bottomSheet: Container(
|
||||
padding: EdgeInsets.only(
|
||||
top: 12,
|
||||
bottom: 12 + MediaQuery.of(context).padding.bottom + 56, // 56 - высота bottomNavigationBar
|
||||
),
|
||||
padding: EdgeInsets.only(bottom: MediaQuery.of(context).padding.bottom + 8),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
border: Border(top: BorderSide(color: Colors.black.withValues(alpha: 0.1))),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
|
||||
@@ -13,10 +13,9 @@ import 'package:eatvio_app/features/profile/presentation/bloc/profile_cubit/prof
|
||||
import 'package:eatvio_app/features/profile/presentation/bloc/profile_cubit/profile_state.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_html/flutter_html.dart';
|
||||
import 'package:eatvio_app/core/widgets/markdown_html_widget.dart';
|
||||
import 'package:flutter_svg/flutter_svg.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:markdown/markdown.dart' as md;
|
||||
import 'package:share_plus/share_plus.dart';
|
||||
|
||||
class ProfileBlogPostWidget extends StatefulWidget {
|
||||
@@ -107,37 +106,48 @@ class _ProfileBlogPostWidgetState extends State<ProfileBlogPostWidget> {
|
||||
if (state.blogPosts[widget.index].description.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
child: Html(
|
||||
data: md.markdownToHtml(
|
||||
state.blogPosts[widget.index].description.length > 200
|
||||
? state.blogPosts[widget.index].description.substring(0, 200).replaceAll('\n', '<br>')
|
||||
: state.blogPosts[widget.index].description.replaceAll('\n', '<br>'),
|
||||
),
|
||||
style: {
|
||||
"body": Style(margin: Margins.zero, padding: HtmlPaddings.zero, fontSize: FontSize(16), lineHeight: const LineHeight(1.5)),
|
||||
"p": Style(margin: Margins.only(bottom: 12), color: Colors.black87),
|
||||
"h1, h2, h3, h4, h5, h6": Style(margin: Margins.only(top: 16, bottom: 8), color: Colors.black, fontWeight: FontWeight.bold),
|
||||
"h1": Style(fontSize: FontSize(24)),
|
||||
"h2": Style(fontSize: FontSize(20)),
|
||||
"h3": Style(fontSize: FontSize(18)),
|
||||
"ul, ol": Style(margin: Margins.only(bottom: 12), padding: HtmlPaddings.only(left: 16)),
|
||||
"li": Style(margin: Margins.only(bottom: 4), color: Colors.black87),
|
||||
"strong, b": Style(fontWeight: FontWeight.bold, color: Colors.black),
|
||||
"em, i": Style(fontStyle: FontStyle.italic),
|
||||
"code": Style(
|
||||
backgroundColor: Colors.grey.shade100,
|
||||
padding: HtmlPaddings.symmetric(horizontal: 4, vertical: 2),
|
||||
fontFamily: 'monospace',
|
||||
),
|
||||
"pre": Style(backgroundColor: Colors.grey.shade100, padding: HtmlPaddings.all(12), margin: Margins.only(bottom: 12)),
|
||||
"blockquote": Style(
|
||||
padding: HtmlPaddings.only(left: 16),
|
||||
margin: Margins.only(left: 8, bottom: 12),
|
||||
fontStyle: FontStyle.italic,
|
||||
color: Colors.grey.shade700,
|
||||
),
|
||||
"a": Style(color: Colors.blue, textDecoration: TextDecoration.underline),
|
||||
"img": Style(width: Width(MediaQuery.of(context).size.width - 32)),
|
||||
child: Builder(
|
||||
builder: (context) {
|
||||
String description = state.blogPosts[widget.index].description;
|
||||
final isLong = description.length > 200;
|
||||
|
||||
if (isLong) {
|
||||
// Умная обрезка: обрезаем markdown текст, не ломая синтаксис
|
||||
int cutPoint = 200;
|
||||
|
||||
// Проверяем, есть ли незакрытые markdown теги
|
||||
String preview = description.substring(0, cutPoint);
|
||||
|
||||
// Считаем открывающие и закрывающие теги
|
||||
int boldOpen = (preview.split('**').length - 1) ~/ 2;
|
||||
int boldClose = preview.split('**').length - 1 - boldOpen * 2;
|
||||
bool hasUnclosedBold = boldOpen > boldClose;
|
||||
|
||||
// Если есть незакрытый bold, ищем закрытие
|
||||
if (hasUnclosedBold) {
|
||||
int searchEnd = (cutPoint + 100).clamp(0, description.length);
|
||||
int boldClosePos = description.indexOf('**', cutPoint);
|
||||
if (boldClosePos != -1 && boldClosePos < searchEnd) {
|
||||
cutPoint = boldClosePos + 2;
|
||||
} else {
|
||||
// Если не нашли, обрезаем до последнего пробела
|
||||
cutPoint = description.lastIndexOf(' ', 200);
|
||||
if (cutPoint == -1 || cutPoint < 150) cutPoint = 200;
|
||||
}
|
||||
} else {
|
||||
// Обрезаем до последнего пробела для красоты
|
||||
cutPoint = description.lastIndexOf(' ', cutPoint);
|
||||
if (cutPoint == -1 || cutPoint < 150) cutPoint = 200;
|
||||
}
|
||||
|
||||
description = description.substring(0, cutPoint);
|
||||
}
|
||||
|
||||
return MarkdownHtmlWidget(
|
||||
markdownText: description,
|
||||
fontSize: 16,
|
||||
textColor: Colors.black87,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
@@ -99,108 +99,102 @@ class _AboutRecipeScreenState extends State<AboutRecipeScreen> {
|
||||
resizeToAvoidBottomInset: true,
|
||||
bottomSheet: Container(
|
||||
padding: EdgeInsets.only(
|
||||
top: 12,
|
||||
bottom: 12 + MediaQuery.of(context).padding.bottom + 56, // 56 - высота bottomNavigationBar
|
||||
bottom: MediaQuery.of(context).padding.bottom + 8,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
border: Border(top: BorderSide(color: Colors.black.withValues(alpha: 0.1))),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
if (replyId != null)
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
context.read<CommentsCubit>().setReplyId(null);
|
||||
setState(() {
|
||||
replyId = null;
|
||||
textEditingController.clear();
|
||||
});
|
||||
FocusManager.instance.primaryFocus?.unfocus();
|
||||
},
|
||||
child: Container(
|
||||
decoration: BoxDecoration(color: appTheme.blueA400, shape: BoxShape.circle),
|
||||
height: 32,
|
||||
width: 32,
|
||||
child: const Icon(Icons.close_rounded, size: 18, color: Colors.white),
|
||||
),
|
||||
),
|
||||
if (replyId != null) const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(minHeight: 52),
|
||||
child: TextFormField(
|
||||
textCapitalization: TextCapitalization.sentences,
|
||||
onChanged: (_) {
|
||||
setState(() {});
|
||||
},
|
||||
controller: textEditingController,
|
||||
maxLines: 5,
|
||||
minLines: 1,
|
||||
keyboardType: TextInputType.multiline,
|
||||
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w400, color: Colors.black, height: 1.2),
|
||||
decoration: InputDecoration(
|
||||
isDense: false,
|
||||
// убираем true, чтобы padding учитывался корректно
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
|
||||
// вертикальный паддинг увеличен
|
||||
hintText: 'Комментарий',
|
||||
hintStyle: const TextStyle(fontSize: 16, fontWeight: FontWeight.w400, height: 1.2, color: Color(0xFFAEAEB2)),
|
||||
filled: true,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
borderSide: const BorderSide(color: Color(0xFFD1D1D6), width: 1),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
borderSide: const BorderSide(color: Color(0xFFD1D1D6), width: 1),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
borderSide: const BorderSide(color: Color(0xFFD1D1D6), width: 1),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (replyId != null)
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
context.read<CommentsCubit>().setReplyId(null);
|
||||
setState(() {
|
||||
replyId = null;
|
||||
textEditingController.clear();
|
||||
});
|
||||
FocusManager.instance.primaryFocus?.unfocus();
|
||||
},
|
||||
child: Container(
|
||||
decoration: BoxDecoration(color: appTheme.blueA400, shape: BoxShape.circle),
|
||||
height: 32,
|
||||
width: 32,
|
||||
child: const Icon(Icons.close_rounded, size: 18, color: Colors.white),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
if (textEditingController.text.length > 2) {
|
||||
commentsCubit.sendComment(
|
||||
id: widget.recipeSlug,
|
||||
commentText: textEditingController.text,
|
||||
parentCommentId: replyId,
|
||||
commentSourceType: CommentSourceType.recipe,
|
||||
);
|
||||
context.read<CommentsCubit>().setReplyId(null);
|
||||
setState(() {
|
||||
textEditingController.clear();
|
||||
replyId = null;
|
||||
});
|
||||
Future.delayed(const Duration(milliseconds: 500), () {
|
||||
recipeCubit.getRecipe(widget.recipeSlug, index: widget.index);
|
||||
});
|
||||
FocusManager.instance.primaryFocus?.unfocus();
|
||||
}
|
||||
),
|
||||
if (replyId != null) const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(minHeight: 52),
|
||||
child: TextFormField(
|
||||
textCapitalization: TextCapitalization.sentences,
|
||||
onChanged: (_) {
|
||||
setState(() {});
|
||||
},
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: textEditingController.text.length > 2 ? appTheme.blueA400 : Colors.grey,
|
||||
shape: BoxShape.circle,
|
||||
controller: textEditingController,
|
||||
maxLines: 5,
|
||||
minLines: 1,
|
||||
keyboardType: TextInputType.multiline,
|
||||
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w400, color: Colors.black, height: 1.2),
|
||||
decoration: InputDecoration(
|
||||
isDense: false,
|
||||
// убираем true, чтобы padding учитывался корректно
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
|
||||
// вертикальный паддинг увеличен
|
||||
hintText: s.comment_hint,
|
||||
hintStyle: const TextStyle(fontSize: 16, fontWeight: FontWeight.w400, height: 1.2, color: Color(0xFFAEAEB2)),
|
||||
filled: true,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
borderSide: const BorderSide(color: Color(0xFFD1D1D6), width: 1),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
borderSide: const BorderSide(color: Color(0xFFD1D1D6), width: 1),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
borderSide: const BorderSide(color: Color(0xFFD1D1D6), width: 1),
|
||||
),
|
||||
height: 32,
|
||||
width: 32,
|
||||
child: const Icon(Icons.send_rounded, size: 18, color: Colors.white),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
if (textEditingController.text.length > 2) {
|
||||
commentsCubit.sendComment(
|
||||
id: widget.recipeSlug,
|
||||
commentText: textEditingController.text,
|
||||
parentCommentId: replyId,
|
||||
commentSourceType: CommentSourceType.recipe,
|
||||
);
|
||||
context.read<CommentsCubit>().setReplyId(null);
|
||||
setState(() {
|
||||
textEditingController.clear();
|
||||
replyId = null;
|
||||
});
|
||||
Future.delayed(const Duration(milliseconds: 500), () {
|
||||
recipeCubit.getRecipe(widget.recipeSlug, index: widget.index);
|
||||
});
|
||||
FocusManager.instance.primaryFocus?.unfocus();
|
||||
}
|
||||
},
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: textEditingController.text.length > 2 ? appTheme.blueA400 : Colors.grey,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
height: 32,
|
||||
width: 32,
|
||||
child: const Icon(Icons.send_rounded, size: 18, color: Colors.white),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -12,12 +12,10 @@ import 'package:eatvio_app/features/recipe/presentation/cubit/my_recipes_cubit/m
|
||||
import 'package:eatvio_app/generated/l10n.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_html/flutter_html.dart';
|
||||
import 'package:eatvio_app/core/widgets/markdown_html_widget.dart';
|
||||
import 'package:flutter_svg/flutter_svg.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:markdown/markdown.dart' as md;
|
||||
import 'package:share_plus/share_plus.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
class MyRecipeWidget extends StatefulWidget {
|
||||
final MyRecipeModel recipe;
|
||||
@@ -100,86 +98,11 @@ class _MyRecipeWidgetState extends State<MyRecipeWidget> {
|
||||
String description = widget.recipe.description.length > 200
|
||||
? '${widget.recipe.description.substring(0, 200)}...'
|
||||
: widget.recipe.description;
|
||||
// Автоматически преобразуем URL в markdown ссылки
|
||||
// Сначала исправляем неправильный синтаксис markdown ссылок
|
||||
final wrongLinkRegex = RegExp(r'\[(https?://[^\]]+|www\.[^\]]+)\]\(([^\)]+)\)');
|
||||
description = description.replaceAllMapped(wrongLinkRegex, (match) {
|
||||
final urlInText = match.group(1)!;
|
||||
if (!urlInText.startsWith('http://') && !urlInText.startsWith('https://') && !urlInText.startsWith('www.')) {
|
||||
return match.group(0)!;
|
||||
}
|
||||
final fullUrl = urlInText.startsWith('www.') ? 'https://$urlInText' : urlInText;
|
||||
return '[$urlInText]($fullUrl)';
|
||||
});
|
||||
|
||||
final linkRegex = RegExp(r'\[([^\]]+)\]\(([^\)]+)\)');
|
||||
final existingLinks = <_LinkRange>[];
|
||||
for (final linkMatch in linkRegex.allMatches(description)) {
|
||||
existingLinks.add(_LinkRange(linkMatch.start, linkMatch.end));
|
||||
}
|
||||
final urlRegex = RegExp(r'(https?://[^\s\)]+|www\.[^\s\)]+)', caseSensitive: false);
|
||||
String result = description;
|
||||
int offset = 0;
|
||||
for (final urlMatch in urlRegex.allMatches(description)) {
|
||||
bool isInsideLink = existingLinks.any((link) =>
|
||||
urlMatch.start >= link.start && urlMatch.end <= link.end);
|
||||
if (!isInsideLink) {
|
||||
final url = urlMatch.group(0)!;
|
||||
final fullUrl = url.startsWith('www.') ? 'https://$url' : url;
|
||||
final replacement = '[$url]($fullUrl)';
|
||||
result = result.replaceRange(
|
||||
urlMatch.start + offset,
|
||||
urlMatch.end + offset,
|
||||
replacement,
|
||||
);
|
||||
offset += replacement.length - url.length;
|
||||
}
|
||||
}
|
||||
description = result;
|
||||
return Html(
|
||||
data: md.markdownToHtml(
|
||||
description.replaceAll('\n', '<br>'),
|
||||
extensionSet: md.ExtensionSet.commonMark,
|
||||
),
|
||||
style: {
|
||||
"body": Style(
|
||||
margin: Margins.zero,
|
||||
padding: HtmlPaddings.zero,
|
||||
fontSize: FontSize(TextStyles.textSMedium.fontSize ?? 14),
|
||||
lineHeight: const LineHeight(1.5),
|
||||
),
|
||||
"p": Style(margin: Margins.only(bottom: 12), color: appTheme.black),
|
||||
"h1, h2, h3, h4, h5, h6": Style(margin: Margins.only(top: 16, bottom: 8), color: appTheme.black, fontWeight: FontWeight.bold),
|
||||
"h1": Style(fontSize: FontSize(24)),
|
||||
"h2": Style(fontSize: FontSize(20)),
|
||||
"h3": Style(fontSize: FontSize(18)),
|
||||
"ul, ol": Style(margin: Margins.only(bottom: 12), padding: HtmlPaddings.only(left: 16)),
|
||||
"li": Style(margin: Margins.only(bottom: 4), color: appTheme.black),
|
||||
"strong, b": Style(fontWeight: FontWeight.bold, color: appTheme.black),
|
||||
"em, i": Style(fontStyle: FontStyle.italic),
|
||||
"code": Style(
|
||||
backgroundColor: Colors.grey.shade100,
|
||||
padding: HtmlPaddings.symmetric(horizontal: 4, vertical: 2),
|
||||
fontFamily: 'monospace',
|
||||
),
|
||||
"pre": Style(backgroundColor: Colors.grey.shade100, padding: HtmlPaddings.all(12), margin: Margins.only(bottom: 12)),
|
||||
"blockquote": Style(
|
||||
padding: HtmlPaddings.only(left: 16),
|
||||
margin: Margins.only(left: 8, bottom: 12),
|
||||
fontStyle: FontStyle.italic,
|
||||
color: Colors.grey.shade700,
|
||||
),
|
||||
"a": Style(color: Colors.blue, textDecoration: TextDecoration.underline),
|
||||
"img": Style(width: Width(MediaQuery.of(context).size.width - 32)),
|
||||
},
|
||||
onLinkTap: (url, attributes, element) async {
|
||||
if (url != null) {
|
||||
final uri = Uri.parse(url);
|
||||
if (await canLaunchUrl(uri)) {
|
||||
await launchUrl(uri, mode: LaunchMode.externalApplication);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
return MarkdownHtmlWidget(
|
||||
markdownText: description,
|
||||
fontSize: TextStyles.textSMedium.fontSize ?? 14,
|
||||
textColor: appTheme.black,
|
||||
);
|
||||
},
|
||||
),
|
||||
@@ -270,8 +193,3 @@ class _MyRecipeWidgetState extends State<MyRecipeWidget> {
|
||||
}
|
||||
}
|
||||
|
||||
class _LinkRange {
|
||||
final int start;
|
||||
final int end;
|
||||
_LinkRange(this.start, this.end);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import 'package:eatvio_app/core/prefs.dart';
|
||||
import 'package:eatvio_app/core/prefs_keys.dart';
|
||||
import 'package:eatvio_app/features/profile/repositories/profile_repository.dart';
|
||||
import 'package:eatvio_app/features/registration/presentation/bloc/verify_email_cubit/verify_email_state.dart';
|
||||
import 'package:eatvio_app/features/registration/services/registration_analytics_service.dart';
|
||||
@@ -5,8 +7,9 @@ import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
class VerifyEmailCubit extends Cubit<VerifyEmailState> {
|
||||
final ProfileRepository profileRepository;
|
||||
final Prefs prefs;
|
||||
|
||||
VerifyEmailCubit({required this.profileRepository}) : super(const VerifyEmailState.loading());
|
||||
VerifyEmailCubit({required this.profileRepository, required this.prefs}) : super(const VerifyEmailState.loading());
|
||||
|
||||
Future<void> sendEmailCode() async {
|
||||
try {
|
||||
@@ -48,4 +51,9 @@ class VerifyEmailCubit extends Cubit<VerifyEmailState> {
|
||||
emit(const VerifyEmailState.changeEmailError());
|
||||
}
|
||||
}
|
||||
|
||||
void skipVerification() {
|
||||
prefs.setBool(PrefsKeys.isEmailVerificationSkipped, true);
|
||||
emit(const VerifyEmailState.skipped());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,4 +23,6 @@ abstract class VerifyEmailState with _$VerifyEmailState {
|
||||
const factory VerifyEmailState.changeEmailSuccess() = VerifyEmailStateChangeEmailSuccess;
|
||||
|
||||
const factory VerifyEmailState.changeEmailError() = VerifyEmailStateChangeEmailError;
|
||||
|
||||
const factory VerifyEmailState.skipped() = VerifyEmailStateSkipped;
|
||||
}
|
||||
|
||||
@@ -101,6 +101,8 @@ class _VerifyEmailScreenState extends State<VerifyEmailScreen> {
|
||||
// Диалог закроется автоматически в ChangeEmailDialog при успешной смене
|
||||
} else if (state is VerifyEmailStateChangeEmailError) {
|
||||
showErrorToast('Ошибка при смене email. Попробуйте снова.');
|
||||
} else if (state is VerifyEmailStateSkipped) {
|
||||
goRouter.pushNamed('feedScreen');
|
||||
}
|
||||
},
|
||||
builder: (context, state) {
|
||||
@@ -163,6 +165,19 @@ class _VerifyEmailScreenState extends State<VerifyEmailScreen> {
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
CustomOutlinedButton(
|
||||
text: 'Позже',
|
||||
height: 52,
|
||||
buttonTextStyle: TextStyles.textMSemibold.copyWith(color: appTheme.black900),
|
||||
buttonStyle: ButtonStyle(
|
||||
side: const WidgetStatePropertyAll(BorderSide.none),
|
||||
backgroundColor: WidgetStatePropertyAll(appTheme.gray.withValues(alpha: 0.1)),
|
||||
),
|
||||
onPressed: () {
|
||||
context.read<VerifyEmailCubit>().skipVerification();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -128,6 +128,7 @@ class MessageLookup extends MessageLookupByLibrary {
|
||||
"Минимум 3 символа",
|
||||
),
|
||||
"authentificate": MessageLookupByLibrary.simpleMessage("Войти"),
|
||||
"authorization": MessageLookupByLibrary.simpleMessage("Authorization"),
|
||||
"average_pressure": MessageLookupByLibrary.simpleMessage(
|
||||
"Средние показатели давления:",
|
||||
),
|
||||
@@ -213,6 +214,7 @@ class MessageLookup extends MessageLookupByLibrary {
|
||||
"commentForMeal": MessageLookupByLibrary.simpleMessage(
|
||||
"Прокомментировать прием пищи",
|
||||
),
|
||||
"comment_hint": MessageLookupByLibrary.simpleMessage("Комментарий"),
|
||||
"comments": MessageLookupByLibrary.simpleMessage("Комментарии"),
|
||||
"comments_plural": MessageLookupByLibrary.simpleMessage("комментариев"),
|
||||
"complain": MessageLookupByLibrary.simpleMessage("Пожаловаться"),
|
||||
|
||||
@@ -47,6 +47,7 @@ class MessageLookup extends MessageLookupByLibrary {
|
||||
"answer": MessageLookupByLibrary.simpleMessage("Ответить"),
|
||||
"archived": MessageLookupByLibrary.simpleMessage("В архиве"),
|
||||
"authentificate": MessageLookupByLibrary.simpleMessage("Войти"),
|
||||
"authorization": MessageLookupByLibrary.simpleMessage("Авторизация"),
|
||||
"average_pressure": MessageLookupByLibrary.simpleMessage(
|
||||
"Средние показатели давления:",
|
||||
),
|
||||
@@ -83,6 +84,7 @@ class MessageLookup extends MessageLookupByLibrary {
|
||||
"city_of_living": MessageLookupByLibrary.simpleMessage("Город проживания"),
|
||||
"comfort": MessageLookupByLibrary.simpleMessage("Комфорт"),
|
||||
"comment": MessageLookupByLibrary.simpleMessage("комментарий"),
|
||||
"comment_hint": MessageLookupByLibrary.simpleMessage("Комментарий"),
|
||||
"comments": MessageLookupByLibrary.simpleMessage("Комментарии"),
|
||||
"comments_plural": MessageLookupByLibrary.simpleMessage("комментариев"),
|
||||
"complain": MessageLookupByLibrary.simpleMessage("Пожаловаться"),
|
||||
|
||||
@@ -664,6 +664,16 @@ class S {
|
||||
);
|
||||
}
|
||||
|
||||
/// `Authorization`
|
||||
String get authorization {
|
||||
return Intl.message(
|
||||
'Authorization',
|
||||
name: 'authorization',
|
||||
desc: '',
|
||||
args: [],
|
||||
);
|
||||
}
|
||||
|
||||
/// `Загрузка...`
|
||||
String get loading {
|
||||
return Intl.message('Загрузка...', name: 'loading', desc: '', args: []);
|
||||
@@ -3958,6 +3968,16 @@ class S {
|
||||
String get invite {
|
||||
return Intl.message('Пригласить', name: 'invite', desc: '', args: []);
|
||||
}
|
||||
|
||||
/// `Комментарий`
|
||||
String get comment_hint {
|
||||
return Intl.message(
|
||||
'Комментарий',
|
||||
name: 'comment_hint',
|
||||
desc: '',
|
||||
args: [],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class AppLocalizationDelegate extends LocalizationsDelegate<S> {
|
||||
|
||||
@@ -69,6 +69,7 @@
|
||||
"no_account": "У меня нет аккаунта",
|
||||
"authentificate": "Войти",
|
||||
"user_authorization": "Аутентификация",
|
||||
"authorization": "Authorization",
|
||||
"loading": "Загрузка...",
|
||||
"weight_chart": "График веса",
|
||||
"circumferences_chart": "График обхватов",
|
||||
@@ -466,6 +467,7 @@
|
||||
"weAreLookingFor": "Мы собираем самое большое сообщество людей, желающих вести здоровый образ жизни!",
|
||||
"letsInvite": "Давайте пригласим близких в итвио",
|
||||
"eatvioIsAHelthyHabbit": "Итвио — это полезная привычка",
|
||||
"invite": "Пригласить"
|
||||
"invite": "Пригласить",
|
||||
"comment_hint": "Комментарий"
|
||||
}
|
||||
|
||||
|
||||
@@ -69,6 +69,7 @@
|
||||
"no_account": "У меня нет аккаунта",
|
||||
"authentificate": "Войти",
|
||||
"user_authorization": "Аутентификация",
|
||||
"authorization": "Авторизация",
|
||||
"loading": "Загрузка...",
|
||||
"weight_chart": "График веса",
|
||||
"circumferences_chart": "График обхватов",
|
||||
@@ -215,5 +216,6 @@
|
||||
"from": "от",
|
||||
"user_data": "Данные пользователя",
|
||||
"infoAboutOrder": "Информация о заказе",
|
||||
"notifications": "Уведомления"
|
||||
"notifications": "Уведомления",
|
||||
"comment_hint": "Комментарий"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
name: eatvio_app
|
||||
description: A new Flutter project.
|
||||
version: 1.3.11+92
|
||||
version: 1.3.13+94
|
||||
publish_to: 'none'
|
||||
|
||||
environment:
|
||||
@@ -43,7 +43,7 @@ dependencies:
|
||||
flutter_keyboard_visibility: ^6.0.0
|
||||
share_plus: ^12.0.1
|
||||
flutter_html: ^3.0.0
|
||||
file_picker: ^10.3.8
|
||||
file_picker: 10.3.8
|
||||
audioplayers: ^6.5.1
|
||||
intl: ^0.20.2
|
||||
mask_text_input_formatter: ^2.9.0
|
||||
@@ -83,6 +83,7 @@ dependencies:
|
||||
rxdart: ^0.27.7
|
||||
gpassword: ^1.0.0
|
||||
webview_flutter_platform_interface: ^2.14.0
|
||||
intl_utils: ^2.8.13
|
||||
|
||||
dev_dependencies:
|
||||
build_runner: ^2.10.4
|
||||
|
||||
@@ -61,5 +61,26 @@ void main() {
|
||||
|
||||
expect(routes.length, greaterThan(0));
|
||||
});
|
||||
|
||||
test('appRouter имеет правильную конфигурацию маршрутов', () {
|
||||
expect(AppRouter.appRouter.configuration.routes, isNotEmpty);
|
||||
});
|
||||
|
||||
test('navigateToRegisterViaSocial - создает правильный extra map', () {
|
||||
const authResult = AuthResultEntity(accessToken: 'test_token', refreshToken: 'refresh_token', userId: '1', clientId: 'test_client_id');
|
||||
const provider = 'yandex';
|
||||
|
||||
expect(() => AppRouter.navigateToRegisterViaSocial(authResult: authResult, provider: provider), returnsNormally);
|
||||
});
|
||||
|
||||
test('appRouter содержит все типы маршрутов', () {
|
||||
final routes = AppRouter.appRouter.configuration.routes;
|
||||
|
||||
// Проверяем, что есть маршруты разных типов
|
||||
expect(routes, isNotEmpty);
|
||||
|
||||
// Проверяем, что есть хотя бы один маршрут
|
||||
expect(routes.length, greaterThan(0));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -4,11 +4,12 @@ import 'package:flutter_test/flutter_test.dart';
|
||||
void main() {
|
||||
group('PrefsKeys', () {
|
||||
test('has all expected values', () {
|
||||
expect(PrefsKeys.values.length, 4);
|
||||
expect(PrefsKeys.values.length, 5);
|
||||
expect(PrefsKeys.values, contains(PrefsKeys.hasAnonymousTokenBeenSent));
|
||||
expect(PrefsKeys.values, contains(PrefsKeys.welcomeScreenWasShowed));
|
||||
expect(PrefsKeys.values, contains(PrefsKeys.registrationDate));
|
||||
expect(PrefsKeys.values, contains(PrefsKeys.marketingBottomSheetHasBeenShown));
|
||||
expect(PrefsKeys.values, contains(PrefsKeys.isEmailVerificationSkipped));
|
||||
});
|
||||
|
||||
test('can be compared', () {
|
||||
@@ -21,6 +22,7 @@ void main() {
|
||||
expect(PrefsKeys.welcomeScreenWasShowed.name, 'welcomeScreenWasShowed');
|
||||
expect(PrefsKeys.registrationDate.name, 'registrationDate');
|
||||
expect(PrefsKeys.marketingBottomSheetHasBeenShown.name, 'marketingBottomSheetHasBeenShown');
|
||||
expect(PrefsKeys.isEmailVerificationSkipped.name, 'isEmailVerificationSkipped');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -35,20 +35,165 @@ void main() {
|
||||
try {
|
||||
// Create a minimal valid JPEG file (1x1 pixel)
|
||||
final jpegBytes = [
|
||||
0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46, 0x00, 0x01,
|
||||
0x01, 0x01, 0x00, 0x48, 0x00, 0x48, 0x00, 0x00, 0xFF, 0xDB, 0x00, 0x43,
|
||||
0x00, 0x08, 0x06, 0x06, 0x07, 0x06, 0x05, 0x08, 0x07, 0x07, 0x07, 0x09,
|
||||
0x09, 0x08, 0x0A, 0x0C, 0x14, 0x0D, 0x0C, 0x0B, 0x0B, 0x0C, 0x19, 0x12,
|
||||
0x13, 0x0F, 0x14, 0x1D, 0x1A, 0x1F, 0x1E, 0x1D, 0x1A, 0x1C, 0x1C, 0x20,
|
||||
0x24, 0x2E, 0x27, 0x20, 0x22, 0x2C, 0x23, 0x1C, 0x1C, 0x28, 0x37, 0x29,
|
||||
0x2C, 0x30, 0x31, 0x34, 0x34, 0x34, 0x1F, 0x27, 0x39, 0x3D, 0x38, 0x32,
|
||||
0x3C, 0x2E, 0x33, 0x34, 0x32, 0xFF, 0xC0, 0x00, 0x0B, 0x08, 0x00, 0x01,
|
||||
0x00, 0x01, 0x01, 0x01, 0x11, 0x00, 0xFF, 0xC4, 0x00, 0x14, 0x00, 0x01,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x08, 0xFF, 0xC4, 0x00, 0x14, 0x10, 0x01, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0x00, 0xFF, 0xDA, 0x00, 0x08, 0x01, 0x01, 0x00, 0x00, 0x3F, 0x00,
|
||||
0x00, 0xFF, 0xD9
|
||||
0xFF,
|
||||
0xD8,
|
||||
0xFF,
|
||||
0xE0,
|
||||
0x00,
|
||||
0x10,
|
||||
0x4A,
|
||||
0x46,
|
||||
0x49,
|
||||
0x46,
|
||||
0x00,
|
||||
0x01,
|
||||
0x01,
|
||||
0x01,
|
||||
0x00,
|
||||
0x48,
|
||||
0x00,
|
||||
0x48,
|
||||
0x00,
|
||||
0x00,
|
||||
0xFF,
|
||||
0xDB,
|
||||
0x00,
|
||||
0x43,
|
||||
0x00,
|
||||
0x08,
|
||||
0x06,
|
||||
0x06,
|
||||
0x07,
|
||||
0x06,
|
||||
0x05,
|
||||
0x08,
|
||||
0x07,
|
||||
0x07,
|
||||
0x07,
|
||||
0x09,
|
||||
0x09,
|
||||
0x08,
|
||||
0x0A,
|
||||
0x0C,
|
||||
0x14,
|
||||
0x0D,
|
||||
0x0C,
|
||||
0x0B,
|
||||
0x0B,
|
||||
0x0C,
|
||||
0x19,
|
||||
0x12,
|
||||
0x13,
|
||||
0x0F,
|
||||
0x14,
|
||||
0x1D,
|
||||
0x1A,
|
||||
0x1F,
|
||||
0x1E,
|
||||
0x1D,
|
||||
0x1A,
|
||||
0x1C,
|
||||
0x1C,
|
||||
0x20,
|
||||
0x24,
|
||||
0x2E,
|
||||
0x27,
|
||||
0x20,
|
||||
0x22,
|
||||
0x2C,
|
||||
0x23,
|
||||
0x1C,
|
||||
0x1C,
|
||||
0x28,
|
||||
0x37,
|
||||
0x29,
|
||||
0x2C,
|
||||
0x30,
|
||||
0x31,
|
||||
0x34,
|
||||
0x34,
|
||||
0x34,
|
||||
0x1F,
|
||||
0x27,
|
||||
0x39,
|
||||
0x3D,
|
||||
0x38,
|
||||
0x32,
|
||||
0x3C,
|
||||
0x2E,
|
||||
0x33,
|
||||
0x34,
|
||||
0x32,
|
||||
0xFF,
|
||||
0xC0,
|
||||
0x00,
|
||||
0x0B,
|
||||
0x08,
|
||||
0x00,
|
||||
0x01,
|
||||
0x00,
|
||||
0x01,
|
||||
0x01,
|
||||
0x01,
|
||||
0x11,
|
||||
0x00,
|
||||
0xFF,
|
||||
0xC4,
|
||||
0x00,
|
||||
0x14,
|
||||
0x00,
|
||||
0x01,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x08,
|
||||
0xFF,
|
||||
0xC4,
|
||||
0x00,
|
||||
0x14,
|
||||
0x10,
|
||||
0x01,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0xFF,
|
||||
0xDA,
|
||||
0x00,
|
||||
0x08,
|
||||
0x01,
|
||||
0x01,
|
||||
0x00,
|
||||
0x00,
|
||||
0x3F,
|
||||
0x00,
|
||||
0x00,
|
||||
0xFF,
|
||||
0xD9,
|
||||
];
|
||||
await tempFile.writeAsBytes(jpegBytes);
|
||||
|
||||
@@ -63,6 +208,223 @@ void main() {
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('compressFilesList - обрабатывает список с изображениями разных форматов', () async {
|
||||
final jpegFile = File('test_file.jpg');
|
||||
final pngFile = File('test_file.png');
|
||||
final gifFile = File('test_file.gif');
|
||||
final nonImageFile = File('test_file.txt');
|
||||
|
||||
try {
|
||||
// Создаем минимальные файлы
|
||||
await jpegFile.writeAsBytes([0xFF, 0xD8, 0xFF, 0xE0]);
|
||||
await pngFile.writeAsBytes([0x89, 0x50, 0x4E, 0x47]);
|
||||
await gifFile.writeAsBytes([0x47, 0x49, 0x46, 0x38]);
|
||||
await nonImageFile.writeAsBytes([0x54, 0x45, 0x58, 0x54]);
|
||||
|
||||
final testFiles = [jpegFile, pngFile, gifFile, nonImageFile];
|
||||
final result = await ImageCompressUtility.compressFilesList(testFiles);
|
||||
|
||||
expect(result, isA<List<File>>());
|
||||
expect(result.length, 4);
|
||||
// В тестовом окружении все файлы возвращаются как есть
|
||||
expect(result[0].path, jpegFile.path);
|
||||
expect(result[1].path, pngFile.path);
|
||||
expect(result[2].path, gifFile.path);
|
||||
expect(result[3].path, nonImageFile.path);
|
||||
} finally {
|
||||
for (final file in [jpegFile, pngFile, gifFile, nonImageFile]) {
|
||||
if (await file.exists()) {
|
||||
await file.delete();
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('compressFilesList - обрабатывает пустой список', () async {
|
||||
final result = await ImageCompressUtility.compressFilesList([]);
|
||||
|
||||
expect(result, isA<List<File>>());
|
||||
expect(result.length, 0);
|
||||
});
|
||||
|
||||
test('compressFilesList - обрабатывает список только с не-изображениями', () async {
|
||||
final txtFile = File('test_file.txt');
|
||||
final pdfFile = File('test_file.pdf');
|
||||
|
||||
try {
|
||||
await txtFile.writeAsBytes([0x54, 0x45, 0x58, 0x54]);
|
||||
await pdfFile.writeAsBytes([0x25, 0x50, 0x44, 0x46]);
|
||||
|
||||
final testFiles = [txtFile, pdfFile];
|
||||
final result = await ImageCompressUtility.compressFilesList(testFiles);
|
||||
|
||||
expect(result, isA<List<File>>());
|
||||
expect(result.length, 2);
|
||||
expect(result[0].path, txtFile.path);
|
||||
expect(result[1].path, pdfFile.path);
|
||||
} finally {
|
||||
for (final file in [txtFile, pdfFile]) {
|
||||
if (await file.exists()) {
|
||||
await file.delete();
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('compressFilesList - обрабатывает файлы с разными расширениями изображений', () async {
|
||||
final jpgFile = File('test_file.jpg');
|
||||
final jpegFile = File('test_file.jpeg');
|
||||
final pngFile = File('test_file.PNG'); // uppercase
|
||||
final bmpFile = File('test_file.bmp');
|
||||
final webpFile = File('test_file.webp');
|
||||
|
||||
try {
|
||||
await jpgFile.writeAsBytes([0xFF, 0xD8]);
|
||||
await jpegFile.writeAsBytes([0xFF, 0xD8]);
|
||||
await pngFile.writeAsBytes([0x89, 0x50]);
|
||||
await bmpFile.writeAsBytes([0x42, 0x4D]);
|
||||
await webpFile.writeAsBytes([0x52, 0x49, 0x46, 0x46]);
|
||||
|
||||
final testFiles = [jpgFile, jpegFile, pngFile, bmpFile, webpFile];
|
||||
final result = await ImageCompressUtility.compressFilesList(testFiles);
|
||||
|
||||
expect(result, isA<List<File>>());
|
||||
expect(result.length, 5);
|
||||
} finally {
|
||||
for (final file in [jpgFile, jpegFile, pngFile, bmpFile, webpFile]) {
|
||||
if (await file.exists()) {
|
||||
await file.delete();
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('compressFile - обрабатывает PNG файл', () async {
|
||||
final pngFile = File('test_file.png');
|
||||
try {
|
||||
// Минимальный валидный PNG
|
||||
final pngBytes = [
|
||||
0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, // PNG signature
|
||||
0x00, 0x00, 0x00, 0x0D, // IHDR chunk length
|
||||
0x49, 0x48, 0x44, 0x52, // IHDR
|
||||
0x00, 0x00, 0x00, 0x01, // width
|
||||
0x00, 0x00, 0x00, 0x01, // height
|
||||
0x08, 0x02, 0x00, 0x00, 0x00, // bit depth, color type, etc.
|
||||
0x90, 0x77, 0x53, 0xDE, // CRC
|
||||
0x00, 0x00, 0x00, 0x00, // IEND chunk length
|
||||
0x49, 0x45, 0x4E, 0x44, // IEND
|
||||
0xAE, 0x42, 0x60, 0x82, // CRC
|
||||
];
|
||||
await pngFile.writeAsBytes(pngBytes);
|
||||
|
||||
final result = await ImageCompressUtility.compressFile(pngFile);
|
||||
|
||||
expect(result, isA<File>());
|
||||
expect(result.path, pngFile.path);
|
||||
} finally {
|
||||
if (await pngFile.exists()) {
|
||||
await pngFile.delete();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('compressFile - обрабатывает файл с расширением в верхнем регистре', () async {
|
||||
final jpgFile = File('test_file.JPG');
|
||||
try {
|
||||
final jpegBytes = [0xFF, 0xD8, 0xFF, 0xE0];
|
||||
await jpgFile.writeAsBytes(jpegBytes);
|
||||
|
||||
final result = await ImageCompressUtility.compressFile(jpgFile);
|
||||
|
||||
expect(result, isA<File>());
|
||||
expect(result.path, jpgFile.path);
|
||||
} finally {
|
||||
if (await jpgFile.exists()) {
|
||||
await jpgFile.delete();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('compressFilesList - обрабатывает смешанный список изображений и не-изображений', () async {
|
||||
final jpgFile = File('test_file.jpg');
|
||||
final txtFile = File('test_file.txt');
|
||||
final pngFile = File('test_file.png');
|
||||
|
||||
try {
|
||||
await jpgFile.writeAsBytes([0xFF, 0xD8]);
|
||||
await txtFile.writeAsBytes([0x54, 0x45, 0x58, 0x54]);
|
||||
await pngFile.writeAsBytes([0x89, 0x50]);
|
||||
|
||||
final testFiles = [jpgFile, txtFile, pngFile];
|
||||
final result = await ImageCompressUtility.compressFilesList(testFiles);
|
||||
|
||||
expect(result, isA<List<File>>());
|
||||
expect(result.length, 3);
|
||||
expect(result[0].path, jpgFile.path);
|
||||
expect(result[1].path, txtFile.path);
|
||||
expect(result[2].path, pngFile.path);
|
||||
} finally {
|
||||
for (final file in [jpgFile, txtFile, pngFile]) {
|
||||
if (await file.exists()) {
|
||||
await file.delete();
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('compressFilesList - обрабатывает файлы с длинными путями', () async {
|
||||
final longPathFile = File('very_long_path_name_for_testing_image_compression.jpg');
|
||||
try {
|
||||
await longPathFile.writeAsBytes([0xFF, 0xD8]);
|
||||
|
||||
final testFiles = [longPathFile];
|
||||
final result = await ImageCompressUtility.compressFilesList(testFiles);
|
||||
|
||||
expect(result, isA<List<File>>());
|
||||
expect(result.length, 1);
|
||||
expect(result[0].path, longPathFile.path);
|
||||
} finally {
|
||||
if (await longPathFile.exists()) {
|
||||
await longPathFile.delete();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('compressFilesList - обрабатывает файлы с точками в имени', () async {
|
||||
final fileWithDots = File('test.file.name.jpg');
|
||||
try {
|
||||
await fileWithDots.writeAsBytes([0xFF, 0xD8]);
|
||||
|
||||
final testFiles = [fileWithDots];
|
||||
final result = await ImageCompressUtility.compressFilesList(testFiles);
|
||||
|
||||
expect(result, isA<List<File>>());
|
||||
expect(result.length, 1);
|
||||
expect(result[0].path, fileWithDots.path);
|
||||
} finally {
|
||||
if (await fileWithDots.exists()) {
|
||||
await fileWithDots.delete();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('compressFilesList - обрабатывает файлы без расширения', () async {
|
||||
final fileNoExt = File('test_file_no_extension');
|
||||
try {
|
||||
await fileNoExt.writeAsBytes([0xFF, 0xD8]);
|
||||
|
||||
final testFiles = [fileNoExt];
|
||||
final result = await ImageCompressUtility.compressFilesList(testFiles);
|
||||
|
||||
expect(result, isA<List<File>>());
|
||||
expect(result.length, 1);
|
||||
expect(result[0].path, fileNoExt.path);
|
||||
} finally {
|
||||
if (await fileNoExt.exists()) {
|
||||
await fileNoExt.delete();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -4,11 +4,12 @@ import 'package:flutter_test/flutter_test.dart';
|
||||
void main() {
|
||||
group('PrefsKeys', () {
|
||||
test('все значения enum определены', () {
|
||||
expect(PrefsKeys.values.length, 4);
|
||||
expect(PrefsKeys.values.length, 5);
|
||||
expect(PrefsKeys.values, contains(PrefsKeys.hasAnonymousTokenBeenSent));
|
||||
expect(PrefsKeys.values, contains(PrefsKeys.welcomeScreenWasShowed));
|
||||
expect(PrefsKeys.values, contains(PrefsKeys.registrationDate));
|
||||
expect(PrefsKeys.values, contains(PrefsKeys.marketingBottomSheetHasBeenShown));
|
||||
expect(PrefsKeys.values, contains(PrefsKeys.isEmailVerificationSkipped));
|
||||
});
|
||||
|
||||
test('имена ключей корректны', () {
|
||||
@@ -16,6 +17,7 @@ void main() {
|
||||
expect(PrefsKeys.welcomeScreenWasShowed.name, 'welcomeScreenWasShowed');
|
||||
expect(PrefsKeys.registrationDate.name, 'registrationDate');
|
||||
expect(PrefsKeys.marketingBottomSheetHasBeenShown.name, 'marketingBottomSheetHasBeenShown');
|
||||
expect(PrefsKeys.isEmailVerificationSkipped.name, 'isEmailVerificationSkipped');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -12,10 +12,7 @@ void main() {
|
||||
|
||||
test('createRoutes содержит маршрут authScreen', () {
|
||||
final routes = AuthRoutes.createRoutes();
|
||||
final authRoute = routes.firstWhere(
|
||||
(route) => route is GoRoute && route.path == '/auth-screen',
|
||||
orElse: () => throw Exception('Route not found'),
|
||||
);
|
||||
final authRoute = routes.firstWhere((route) => route is GoRoute && route.path == '/auth-screen', orElse: () => throw Exception('Route not found'));
|
||||
expect(authRoute, isA<GoRoute>());
|
||||
});
|
||||
|
||||
@@ -67,10 +64,7 @@ void main() {
|
||||
|
||||
test('createRoutes содержит маршрут verifyScreen', () {
|
||||
final routes = AuthRoutes.createRoutes();
|
||||
final verifyRoute = routes.firstWhere(
|
||||
(route) => route is GoRoute && route.path == '/verify-screen',
|
||||
orElse: () => throw Exception('Route not found'),
|
||||
);
|
||||
final verifyRoute = routes.firstWhere((route) => route is GoRoute && route.path == '/verify-screen', orElse: () => throw Exception('Route not found'));
|
||||
expect(verifyRoute, isA<GoRoute>());
|
||||
});
|
||||
|
||||
@@ -92,44 +86,25 @@ void main() {
|
||||
expect(registerViaSocialRoute, isA<GoRoute>());
|
||||
});
|
||||
|
||||
test('authScreen имеет redirect функцию', () {
|
||||
final routes = AuthRoutes.createRoutes();
|
||||
final authRoute = routes.first as GoRoute;
|
||||
expect(authRoute.redirect, isNotNull);
|
||||
});
|
||||
|
||||
test('forgetPasswordScreen builder обрабатывает extra данные', () {
|
||||
final routes = AuthRoutes.createRoutes();
|
||||
final authRoute = routes.first as GoRoute;
|
||||
final forgetPasswordRoute = authRoute.routes.firstWhere(
|
||||
(route) => route is GoRoute && route.path == 'forget-password-screen',
|
||||
orElse: () => throw Exception('Route not found'),
|
||||
) as GoRoute;
|
||||
final forgetPasswordRoute =
|
||||
authRoute.routes.firstWhere((route) => route is GoRoute && route.path == 'forget-password-screen', orElse: () => throw Exception('Route not found'))
|
||||
as GoRoute;
|
||||
expect(forgetPasswordRoute.builder, isNotNull);
|
||||
});
|
||||
|
||||
test('verifyScreen builder обрабатывает extra данные', () {
|
||||
final routes = AuthRoutes.createRoutes();
|
||||
final verifyRoute = routes.firstWhere(
|
||||
(route) => route is GoRoute && route.path == '/verify-screen',
|
||||
) as GoRoute;
|
||||
final verifyRoute = routes.firstWhere((route) => route is GoRoute && route.path == '/verify-screen') as GoRoute;
|
||||
expect(verifyRoute.builder, isNotNull);
|
||||
});
|
||||
|
||||
test('registerViaSocialScreen builder обрабатывает extra данные', () {
|
||||
final routes = AuthRoutes.createRoutes();
|
||||
final registerViaSocialRoute = routes.firstWhere(
|
||||
(route) => route is GoRoute && route.path == '/register-via-social-screen',
|
||||
) as GoRoute;
|
||||
final registerViaSocialRoute = routes.firstWhere((route) => route is GoRoute && route.path == '/register-via-social-screen') as GoRoute;
|
||||
expect(registerViaSocialRoute.builder, isNotNull);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -135,6 +135,91 @@ void main() {
|
||||
|
||||
expect(blogPostRoute.builder, isNotNull);
|
||||
});
|
||||
|
||||
test('aboutPlanScreen builder обрабатывает slug из query параметров с navigateToComments false', () {
|
||||
final routes = ContentRoutes.createRoutes();
|
||||
final planRoute = routes.firstWhere(
|
||||
(route) => route is GoRoute && route.path == '/about-plan-screen',
|
||||
) as GoRoute;
|
||||
|
||||
expect(planRoute.builder, isNotNull);
|
||||
expect(planRoute.name, 'aboutPlanScreen');
|
||||
});
|
||||
|
||||
test('aboutRecipesScreen builder обрабатывает slug из query параметров с navigateToComments false', () {
|
||||
final routes = ContentRoutes.createRoutes();
|
||||
final recipeRoute = routes.firstWhere(
|
||||
(route) => route is GoRoute && route.path == '/about-recipes-screen',
|
||||
) as GoRoute;
|
||||
|
||||
expect(recipeRoute.builder, isNotNull);
|
||||
expect(recipeRoute.name, 'aboutRecipesScreen');
|
||||
});
|
||||
|
||||
test('aboutDietScreen builder обрабатывает slug из query параметров с index null', () {
|
||||
final routes = ContentRoutes.createRoutes();
|
||||
final dietRoute = routes.firstWhere(
|
||||
(route) => route is GoRoute && route.path == '/about-diet-screen',
|
||||
) as GoRoute;
|
||||
|
||||
expect(dietRoute.builder, isNotNull);
|
||||
expect(dietRoute.name, 'aboutDietScreen');
|
||||
});
|
||||
|
||||
test('blogPostScreen builder обрабатывает id из query параметров как int', () {
|
||||
final routes = ContentRoutes.createRoutes();
|
||||
final blogPostRoute = routes.firstWhere(
|
||||
(route) => route is GoRoute && route.path == '/blog-post-screen',
|
||||
) as GoRoute;
|
||||
|
||||
expect(blogPostRoute.builder, isNotNull);
|
||||
expect(blogPostRoute.name, 'blogPostScreen');
|
||||
});
|
||||
|
||||
test('blogPostScreen builder обрабатывает id из extra как int', () {
|
||||
final routes = ContentRoutes.createRoutes();
|
||||
final blogPostRoute = routes.firstWhere(
|
||||
(route) => route is GoRoute && route.path == '/blog-post-screen',
|
||||
) as GoRoute;
|
||||
|
||||
expect(blogPostRoute.builder, isNotNull);
|
||||
});
|
||||
|
||||
test('aboutPlanScreen builder обрабатывает extra данные с index', () {
|
||||
final routes = ContentRoutes.createRoutes();
|
||||
final planRoute = routes.firstWhere(
|
||||
(route) => route is GoRoute && route.path == '/about-plan-screen',
|
||||
) as GoRoute;
|
||||
|
||||
expect(planRoute.builder, isNotNull);
|
||||
});
|
||||
|
||||
test('aboutRecipesScreen builder обрабатывает extra данные с index', () {
|
||||
final routes = ContentRoutes.createRoutes();
|
||||
final recipeRoute = routes.firstWhere(
|
||||
(route) => route is GoRoute && route.path == '/about-recipes-screen',
|
||||
) as GoRoute;
|
||||
|
||||
expect(recipeRoute.builder, isNotNull);
|
||||
});
|
||||
|
||||
test('aboutDietScreen builder обрабатывает extra данные с id и slug', () {
|
||||
final routes = ContentRoutes.createRoutes();
|
||||
final dietRoute = routes.firstWhere(
|
||||
(route) => route is GoRoute && route.path == '/about-diet-screen',
|
||||
) as GoRoute;
|
||||
|
||||
expect(dietRoute.builder, isNotNull);
|
||||
});
|
||||
|
||||
test('blogPostCreateScreen builder обрабатывает null extra', () {
|
||||
final routes = ContentRoutes.createRoutes();
|
||||
final blogPostCreateRoute = routes.firstWhere(
|
||||
(route) => route is GoRoute && route.path == '/blog-post-create-screen',
|
||||
) as GoRoute;
|
||||
|
||||
expect(blogPostCreateRoute.builder, isNotNull);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,109 @@ void main() {
|
||||
final routes = MainNavigationRoutes.createRoutes();
|
||||
expect(routes.first, isA<ShellRoute>());
|
||||
});
|
||||
|
||||
test('ShellRoute содержит маршрут myProfileScreen', () {
|
||||
final routes = MainNavigationRoutes.createRoutes();
|
||||
final shellRoute = routes.first as ShellRoute;
|
||||
final myProfileRoute = shellRoute.routes.firstWhere(
|
||||
(route) => route is GoRoute && route.path == '/my-profile-screen',
|
||||
orElse: () => throw Exception('Route not found'),
|
||||
);
|
||||
expect(myProfileRoute, isA<GoRoute>());
|
||||
expect((myProfileRoute as GoRoute).name, 'myProfileScreen');
|
||||
});
|
||||
|
||||
test('ShellRoute содержит маршрут feedScreen', () {
|
||||
final routes = MainNavigationRoutes.createRoutes();
|
||||
final shellRoute = routes.first as ShellRoute;
|
||||
final feedRoute = shellRoute.routes.firstWhere(
|
||||
(route) => route is GoRoute && route.path == '/feed-screen',
|
||||
orElse: () => throw Exception('Route not found'),
|
||||
);
|
||||
expect(feedRoute, isA<GoRoute>());
|
||||
expect((feedRoute as GoRoute).name, 'feedScreen');
|
||||
});
|
||||
|
||||
test('ShellRoute содержит маршрут diaryScreen', () {
|
||||
final routes = MainNavigationRoutes.createRoutes();
|
||||
final shellRoute = routes.first as ShellRoute;
|
||||
final diaryRoute = shellRoute.routes.firstWhere(
|
||||
(route) => route is GoRoute && route.path == '/diary-screen',
|
||||
orElse: () => throw Exception('Route not found'),
|
||||
);
|
||||
expect(diaryRoute, isA<GoRoute>());
|
||||
expect((diaryRoute as GoRoute).name, 'diaryScreen');
|
||||
});
|
||||
|
||||
test('ShellRoute содержит маршрут consultationsScreen', () {
|
||||
final routes = MainNavigationRoutes.createRoutes();
|
||||
final shellRoute = routes.first as ShellRoute;
|
||||
final consultationsRoute = shellRoute.routes.firstWhere(
|
||||
(route) => route is GoRoute && route.path == '/consultations-screen',
|
||||
orElse: () => throw Exception('Route not found'),
|
||||
);
|
||||
expect(consultationsRoute, isA<GoRoute>());
|
||||
});
|
||||
|
||||
test('ShellRoute содержит маршрут chatSelectScreen', () {
|
||||
final routes = MainNavigationRoutes.createRoutes();
|
||||
final shellRoute = routes.first as ShellRoute;
|
||||
final chatSelectRoute = shellRoute.routes.firstWhere(
|
||||
(route) => route is GoRoute && route.path == '/chat-select-screen',
|
||||
orElse: () => throw Exception('Route not found'),
|
||||
);
|
||||
expect(chatSelectRoute, isA<GoRoute>());
|
||||
expect((chatSelectRoute as GoRoute).name, 'chatSelectScreen');
|
||||
});
|
||||
|
||||
test('ShellRoute содержит маршрут notificationsScreen', () {
|
||||
final routes = MainNavigationRoutes.createRoutes();
|
||||
final shellRoute = routes.first as ShellRoute;
|
||||
final notificationsRoute = shellRoute.routes.firstWhere(
|
||||
(route) => route is GoRoute && route.path == '/notifications-screen',
|
||||
orElse: () => throw Exception('Route not found'),
|
||||
);
|
||||
expect(notificationsRoute, isA<GoRoute>());
|
||||
expect((notificationsRoute as GoRoute).name, 'notificationsScreen');
|
||||
});
|
||||
|
||||
test('ShellRoute содержит маршрут menuScreen', () {
|
||||
final routes = MainNavigationRoutes.createRoutes();
|
||||
final shellRoute = routes.first as ShellRoute;
|
||||
final menuRoute = shellRoute.routes.firstWhere(
|
||||
(route) => route is GoRoute && route.path == '/menu-screen',
|
||||
orElse: () => throw Exception('Route not found'),
|
||||
);
|
||||
expect(menuRoute, isA<GoRoute>());
|
||||
expect((menuRoute as GoRoute).name, 'menuScreen');
|
||||
});
|
||||
|
||||
test('feedScreen имеет redirect функцию', () {
|
||||
final routes = MainNavigationRoutes.createRoutes();
|
||||
final shellRoute = routes.first as ShellRoute;
|
||||
final feedRoute = shellRoute.routes.firstWhere(
|
||||
(route) => route is GoRoute && route.path == '/feed-screen',
|
||||
) as GoRoute;
|
||||
|
||||
expect(feedRoute.redirect, isNotNull);
|
||||
});
|
||||
|
||||
test('menuScreen использует BlocBuilder для ProfileAvatarCubit', () {
|
||||
final routes = MainNavigationRoutes.createRoutes();
|
||||
final shellRoute = routes.first as ShellRoute;
|
||||
final menuRoute = shellRoute.routes.firstWhere(
|
||||
(route) => route is GoRoute && route.path == '/menu-screen',
|
||||
) as GoRoute;
|
||||
|
||||
expect(menuRoute.pageBuilder, isNotNull);
|
||||
});
|
||||
|
||||
test('ShellRoute builder инициализирует ProfileAvatarCubit', () {
|
||||
final routes = MainNavigationRoutes.createRoutes();
|
||||
final shellRoute = routes.first as ShellRoute;
|
||||
|
||||
expect(shellRoute.builder, isNotNull);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:eatvio_app/core/routing/other_routes.dart';
|
||||
import 'package:eatvio_app/core/routing/route_constants.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
@@ -77,5 +78,149 @@ void main() {
|
||||
routes.firstWhere((route) => route is GoRoute && route.path == '/update-screen', orElse: () => throw Exception('Route not found')) as GoRoute;
|
||||
expect(updateRoute.redirect, isNotNull);
|
||||
});
|
||||
|
||||
test('createRoutes содержит маршрут chatScreen', () {
|
||||
final routes = OtherRoutes.createRoutes();
|
||||
final chatRoute = routes.firstWhere((route) => route is GoRoute && route.path == '/chat-screen', orElse: () => throw Exception('Route not found'));
|
||||
expect(chatRoute, isA<GoRoute>());
|
||||
});
|
||||
|
||||
test('createRoutes содержит маршрут emptyChatScreen', () {
|
||||
final routes = OtherRoutes.createRoutes();
|
||||
final emptyChatRoute = routes.firstWhere(
|
||||
(route) => route is GoRoute && route.path == '/empty-chat-screen',
|
||||
orElse: () => throw Exception('Route not found'),
|
||||
);
|
||||
expect(emptyChatRoute, isA<GoRoute>());
|
||||
});
|
||||
|
||||
test('createRoutes содержит маршрут createDietScreen', () {
|
||||
final routes = OtherRoutes.createRoutes();
|
||||
final createDietRoute = routes.firstWhere(
|
||||
(route) => route is GoRoute && route.path == '/create-diet-screen',
|
||||
orElse: () => throw Exception('Route not found'),
|
||||
);
|
||||
expect(createDietRoute, isA<GoRoute>());
|
||||
});
|
||||
|
||||
test('createRoutes содержит маршрут dietPreviewScreen', () {
|
||||
final routes = OtherRoutes.createRoutes();
|
||||
final dietPreviewRoute = routes.firstWhere(
|
||||
(route) => route is GoRoute && route.path == '/create-diet-screen/preview',
|
||||
orElse: () => throw Exception('Route not found'),
|
||||
);
|
||||
expect(dietPreviewRoute, isA<GoRoute>());
|
||||
});
|
||||
|
||||
test('createRoutes содержит маршрут myDietsScreen', () {
|
||||
final routes = OtherRoutes.createRoutes();
|
||||
final myDietsRoute = routes.firstWhere((route) => route is GoRoute && route.path == '/my-diets-screen', orElse: () => throw Exception('Route not found'));
|
||||
expect(myDietsRoute, isA<GoRoute>());
|
||||
});
|
||||
|
||||
test('createRoutes содержит маршрут myPlansScreen', () {
|
||||
final routes = OtherRoutes.createRoutes();
|
||||
final myPlansRoute = routes.firstWhere((route) => route is GoRoute && route.path == '/my-plans-screen', orElse: () => throw Exception('Route not found'));
|
||||
expect(myPlansRoute, isA<GoRoute>());
|
||||
});
|
||||
|
||||
test('createRoutes содержит маршрут myRecipesScreen', () {
|
||||
final routes = OtherRoutes.createRoutes();
|
||||
final myRecipesRoute = routes.firstWhere(
|
||||
(route) => route is GoRoute && route.path == '/my-recipes-screen',
|
||||
orElse: () => throw Exception('Route not found'),
|
||||
);
|
||||
expect(myRecipesRoute, isA<GoRoute>());
|
||||
});
|
||||
|
||||
test('createRoutes содержит маршрут userFinanceScreen', () {
|
||||
final routes = OtherRoutes.createRoutes();
|
||||
final userFinanceRoute = routes.firstWhere(
|
||||
(route) => route is GoRoute && route.path == '/user-finance-screen',
|
||||
orElse: () => throw Exception('Route not found'),
|
||||
);
|
||||
expect(userFinanceRoute, isA<GoRoute>());
|
||||
});
|
||||
|
||||
test('createRoutes содержит маршрут consultationsArchiveScreen', () {
|
||||
final routes = OtherRoutes.createRoutes();
|
||||
final consultationsArchiveRoute = routes.firstWhere(
|
||||
(route) => route is GoRoute && route.path == '/consultations-archive-screen',
|
||||
orElse: () => throw Exception('Route not found'),
|
||||
);
|
||||
expect(consultationsArchiveRoute, isA<GoRoute>());
|
||||
});
|
||||
|
||||
test('createRoutes содержит маршрут consultantFinancesScreen', () {
|
||||
final routes = OtherRoutes.createRoutes();
|
||||
final consultantFinancesRoute = routes.firstWhere(
|
||||
(route) => route is GoRoute && route.path == '/consultant-finances-screen',
|
||||
orElse: () => throw Exception('Route not found'),
|
||||
);
|
||||
expect(consultantFinancesRoute, isA<GoRoute>());
|
||||
});
|
||||
|
||||
test('createRoutes содержит маршрут userPurchasesScreen', () {
|
||||
final routes = OtherRoutes.createRoutes();
|
||||
final userPurchasesRoute = routes.firstWhere(
|
||||
(route) => route is GoRoute && route.path == '/user-purchases-screen',
|
||||
orElse: () => throw Exception('Route not found'),
|
||||
);
|
||||
expect(userPurchasesRoute, isA<GoRoute>());
|
||||
});
|
||||
|
||||
test('createRoutes содержит маршрут stepperBecomeConsultantScreen', () {
|
||||
final routes = OtherRoutes.createRoutes();
|
||||
final stepperBecomeConsultantRoute = routes.firstWhere(
|
||||
(route) => route is GoRoute && route.path == '/stepper-become-consultant-screen',
|
||||
orElse: () => throw Exception('Route not found'),
|
||||
);
|
||||
expect(stepperBecomeConsultantRoute, isA<GoRoute>());
|
||||
});
|
||||
|
||||
test('createRoutes содержит маршрут photoViewScreen', () {
|
||||
final routes = OtherRoutes.createRoutes();
|
||||
final photoViewRoute = routes.firstWhere(
|
||||
(route) => route is GoRoute && route.path == RouteConstants.photoViewScreen,
|
||||
orElse: () => throw Exception('Route not found'),
|
||||
);
|
||||
expect(photoViewRoute, isA<GoRoute>());
|
||||
});
|
||||
|
||||
test('createRoutes содержит маршрут userDetailsScreen', () {
|
||||
final routes = OtherRoutes.createRoutes();
|
||||
final userDetailsRoute = routes.firstWhere(
|
||||
(route) => route is GoRoute && route.path == '/user-details-screen',
|
||||
orElse: () => throw Exception('Route not found'),
|
||||
);
|
||||
expect(userDetailsRoute, isA<GoRoute>());
|
||||
});
|
||||
|
||||
test('createRoutes содержит маршрут personalDietSuggestionScreen', () {
|
||||
final routes = OtherRoutes.createRoutes();
|
||||
final personalDietSuggestionRoute = routes.firstWhere(
|
||||
(route) => route is GoRoute && route.path == '/personal-diet-suggestion',
|
||||
orElse: () => throw Exception('Route not found'),
|
||||
);
|
||||
expect(personalDietSuggestionRoute, isA<GoRoute>());
|
||||
});
|
||||
|
||||
test('createRoutes содержит маршрут blockedUsersScreen', () {
|
||||
final routes = OtherRoutes.createRoutes();
|
||||
final blockedUsersRoute = routes.firstWhere(
|
||||
(route) => route is GoRoute && route.path == '/blocked-users-screen',
|
||||
orElse: () => throw Exception('Route not found'),
|
||||
);
|
||||
expect(blockedUsersRoute, isA<GoRoute>());
|
||||
});
|
||||
|
||||
test('createRoutes содержит маршрут notificationsSettingsScreen', () {
|
||||
final routes = OtherRoutes.createRoutes();
|
||||
final notificationsSettingsRoute = routes.firstWhere(
|
||||
(route) => route is GoRoute && route.path == '/notifications-settings-screen',
|
||||
orElse: () => throw Exception('Route not found'),
|
||||
);
|
||||
expect(notificationsSettingsRoute, isA<GoRoute>());
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -6,6 +6,10 @@ void main() {
|
||||
group('TokenStorage', () {
|
||||
setUp(() async {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
// Clear cached instance to ensure fresh state for each test
|
||||
TokenStorage.clearCache();
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.clear();
|
||||
});
|
||||
|
||||
test('haveTokenInStorage returns false when no token is stored', () async {
|
||||
@@ -66,6 +70,10 @@ void main() {
|
||||
group('SocketIdStorage', () {
|
||||
setUp(() async {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
// Clear cached instance to ensure fresh state for each test
|
||||
TokenStorage.clearCache();
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.clear();
|
||||
});
|
||||
|
||||
test('haveSocketIdInStorage returns false when no socketId is stored', () async {
|
||||
|
||||
@@ -53,5 +53,205 @@ void main() {
|
||||
expect(image.contains('assets/') || image.contains(ImageConstant.imagePath), isTrue);
|
||||
}
|
||||
});
|
||||
|
||||
test('personal cabinet images are defined', () {
|
||||
expect(ImageConstant.imgEdit, isNotEmpty);
|
||||
expect(ImageConstant.imgStatistic, isNotEmpty);
|
||||
expect(ImageConstant.imgStatisticGray200, isNotEmpty);
|
||||
expect(ImageConstant.imgMegaphone, isNotEmpty);
|
||||
});
|
||||
|
||||
test('common ellipse images are defined', () {
|
||||
expect(ImageConstant.imgEllipse1, isNotEmpty);
|
||||
expect(ImageConstant.imgEllipse2, isNotEmpty);
|
||||
expect(ImageConstant.imgEllipse3, isNotEmpty);
|
||||
expect(ImageConstant.imgEllipse11, isNotEmpty);
|
||||
expect(ImageConstant.imgEllipse125x25, isNotEmpty);
|
||||
expect(ImageConstant.imgEllipse132x32, isNotEmpty);
|
||||
expect(ImageConstant.imgEllipse134x34, isNotEmpty);
|
||||
expect(ImageConstant.imgEllipse142x42, isNotEmpty);
|
||||
});
|
||||
|
||||
test('common UI images are defined', () {
|
||||
expect(ImageConstant.imgGrid, isNotEmpty);
|
||||
expect(ImageConstant.imgSearch, isNotEmpty);
|
||||
expect(ImageConstant.imgArea1, isNotEmpty);
|
||||
expect(ImageConstant.imgLine1, isNotEmpty);
|
||||
expect(ImageConstant.imgLine2, isNotEmpty);
|
||||
expect(ImageConstant.imgLine3, isNotEmpty);
|
||||
expect(ImageConstant.imgEye, isNotEmpty);
|
||||
expect(ImageConstant.imgFavoriteWhiteA700, isNotEmpty);
|
||||
expect(ImageConstant.img, isNotEmpty);
|
||||
});
|
||||
|
||||
test('navigation images are defined', () {
|
||||
expect(ImageConstant.imgDiary, isNotEmpty);
|
||||
expect(ImageConstant.imgFeed, isNotEmpty);
|
||||
expect(ImageConstant.imgChat, isNotEmpty);
|
||||
expect(ImageConstant.imgLogo, isNotEmpty);
|
||||
expect(ImageConstant.imgBell, isNotEmpty);
|
||||
expect(ImageConstant.imgSettings, isNotEmpty);
|
||||
expect(ImageConstant.imgRecipes, isNotEmpty);
|
||||
expect(ImageConstant.imgDiets, isNotEmpty);
|
||||
expect(ImageConstant.imgPlans, isNotEmpty);
|
||||
expect(ImageConstant.imgFinance, isNotEmpty);
|
||||
expect(ImageConstant.imgSupport, isNotEmpty);
|
||||
expect(ImageConstant.imgMic, isNotEmpty);
|
||||
expect(ImageConstant.imgSend, isNotEmpty);
|
||||
expect(ImageConstant.imgAttach, isNotEmpty);
|
||||
expect(ImageConstant.imgExit, isNotEmpty);
|
||||
});
|
||||
|
||||
test('navigation color variants are defined', () {
|
||||
expect(ImageConstant.imgNavBlueGray400, isNotEmpty);
|
||||
expect(ImageConstant.imgNavBlueGray40024x23, isNotEmpty);
|
||||
expect(ImageConstant.imgNavPrimary, isNotEmpty);
|
||||
expect(ImageConstant.imgNavBlueGray40024x25, isNotEmpty);
|
||||
});
|
||||
|
||||
test('icon images are defined', () {
|
||||
expect(ImageConstant.imgIconMoreHorizontal, isNotEmpty);
|
||||
expect(ImageConstant.imgIconMessageSquare, isNotEmpty);
|
||||
expect(ImageConstant.imgIconHeart, isNotEmpty);
|
||||
expect(ImageConstant.imgIconShare2, isNotEmpty);
|
||||
});
|
||||
|
||||
test('new icons from 21 08 2024 are defined', () {
|
||||
expect(ImageConstant.icnBell, isNotEmpty);
|
||||
expect(ImageConstant.icnCcal, isNotEmpty);
|
||||
expect(ImageConstant.icnChat, isNotEmpty);
|
||||
expect(ImageConstant.icnClock, isNotEmpty);
|
||||
expect(ImageConstant.icnComment, isNotEmpty);
|
||||
expect(ImageConstant.icnConsultation, isNotEmpty);
|
||||
expect(ImageConstant.icnCopy, isNotEmpty);
|
||||
expect(ImageConstant.icnDiary, isNotEmpty);
|
||||
expect(ImageConstant.icnDietCarbs, isNotEmpty);
|
||||
expect(ImageConstant.icnDietLight, isNotEmpty);
|
||||
expect(ImageConstant.icnExit, isNotEmpty);
|
||||
expect(ImageConstant.icnEye, isNotEmpty);
|
||||
expect(ImageConstant.icnFaceHappy, isNotEmpty);
|
||||
expect(ImageConstant.icnFaceSad, isNotEmpty);
|
||||
expect(ImageConstant.icnFavoritePosts, isNotEmpty);
|
||||
expect(ImageConstant.icnFavoriteRecipes, isNotEmpty);
|
||||
expect(ImageConstant.icnFeed, isNotEmpty);
|
||||
expect(ImageConstant.icnFinance, isNotEmpty);
|
||||
expect(ImageConstant.icnGlossary, isNotEmpty);
|
||||
expect(ImageConstant.icnGym, isNotEmpty);
|
||||
expect(ImageConstant.icnLikeFilled, isNotEmpty);
|
||||
expect(ImageConstant.icnLikePlus, isNotEmpty);
|
||||
expect(ImageConstant.icnLike, isNotEmpty);
|
||||
expect(ImageConstant.icnMeat, isNotEmpty);
|
||||
expect(ImageConstant.icnPaste, isNotEmpty);
|
||||
expect(ImageConstant.icnPlans, isNotEmpty);
|
||||
expect(ImageConstant.icnPosts, isNotEmpty);
|
||||
expect(ImageConstant.icnProfileSettings, isNotEmpty);
|
||||
expect(ImageConstant.icnRating, isNotEmpty);
|
||||
expect(ImageConstant.icnRecipes, isNotEmpty);
|
||||
expect(ImageConstant.icnScale, isNotEmpty);
|
||||
expect(ImageConstant.icnSend, isNotEmpty);
|
||||
expect(ImageConstant.icnShare, isNotEmpty);
|
||||
expect(ImageConstant.icnSpoonFork, isNotEmpty);
|
||||
expect(ImageConstant.icnStepsGraph, isNotEmpty);
|
||||
expect(ImageConstant.icnSteps, isNotEmpty);
|
||||
expect(ImageConstant.icnSubscribers, isNotEmpty);
|
||||
expect(ImageConstant.icnSupport, isNotEmpty);
|
||||
expect(ImageConstant.icnDevTools, isNotEmpty);
|
||||
expect(ImageConstant.icnVeggies, isNotEmpty);
|
||||
});
|
||||
|
||||
test('all image constants use correct path format', () {
|
||||
final allImageConstants = [
|
||||
ImageConstant.imgEdit,
|
||||
ImageConstant.imgStatistic,
|
||||
ImageConstant.imgStatisticGray200,
|
||||
ImageConstant.imgMegaphone,
|
||||
ImageConstant.imgEllipse1,
|
||||
ImageConstant.imgEllipse2,
|
||||
ImageConstant.imgEllipse3,
|
||||
ImageConstant.imgEllipse11,
|
||||
ImageConstant.imgEllipse125x25,
|
||||
ImageConstant.imgEllipse132x32,
|
||||
ImageConstant.imgEllipse134x34,
|
||||
ImageConstant.imgEllipse142x42,
|
||||
ImageConstant.imgGrid,
|
||||
ImageConstant.imgSearch,
|
||||
ImageConstant.imgArea1,
|
||||
ImageConstant.imgLine1,
|
||||
ImageConstant.imgLine2,
|
||||
ImageConstant.imgLine3,
|
||||
ImageConstant.imgEye,
|
||||
ImageConstant.imgFavoriteWhiteA700,
|
||||
ImageConstant.img,
|
||||
ImageConstant.imgDiary,
|
||||
ImageConstant.imgFeed,
|
||||
ImageConstant.imgChat,
|
||||
ImageConstant.imgLogo,
|
||||
ImageConstant.imgBell,
|
||||
ImageConstant.imgSettings,
|
||||
ImageConstant.imgRecipes,
|
||||
ImageConstant.imgDiets,
|
||||
ImageConstant.imgPlans,
|
||||
ImageConstant.imgFinance,
|
||||
ImageConstant.imgSupport,
|
||||
ImageConstant.imgMic,
|
||||
ImageConstant.imgSend,
|
||||
ImageConstant.imgAttach,
|
||||
ImageConstant.imgExit,
|
||||
ImageConstant.imgNavBlueGray400,
|
||||
ImageConstant.imgNavBlueGray40024x23,
|
||||
ImageConstant.imgNavPrimary,
|
||||
ImageConstant.imgNavBlueGray40024x25,
|
||||
ImageConstant.imgIconMoreHorizontal,
|
||||
ImageConstant.imgIconMessageSquare,
|
||||
ImageConstant.imgIconHeart,
|
||||
ImageConstant.imgIconShare2,
|
||||
ImageConstant.imgRectangle14250x311,
|
||||
ImageConstant.icnBell,
|
||||
ImageConstant.icnCcal,
|
||||
ImageConstant.icnChat,
|
||||
ImageConstant.icnClock,
|
||||
ImageConstant.icnComment,
|
||||
ImageConstant.icnConsultation,
|
||||
ImageConstant.icnCopy,
|
||||
ImageConstant.icnDiary,
|
||||
ImageConstant.icnDietCarbs,
|
||||
ImageConstant.icnDietLight,
|
||||
ImageConstant.icnExit,
|
||||
ImageConstant.icnEye,
|
||||
ImageConstant.icnFaceHappy,
|
||||
ImageConstant.icnFaceSad,
|
||||
ImageConstant.icnFavoritePosts,
|
||||
ImageConstant.icnFavoriteRecipes,
|
||||
ImageConstant.icnFeed,
|
||||
ImageConstant.icnFinance,
|
||||
ImageConstant.icnGlossary,
|
||||
ImageConstant.icnGym,
|
||||
ImageConstant.icnLikeFilled,
|
||||
ImageConstant.icnLikePlus,
|
||||
ImageConstant.icnLike,
|
||||
ImageConstant.icnMeat,
|
||||
ImageConstant.icnPaste,
|
||||
ImageConstant.icnPlans,
|
||||
ImageConstant.icnPosts,
|
||||
ImageConstant.icnProfileSettings,
|
||||
ImageConstant.icnRating,
|
||||
ImageConstant.icnRecipes,
|
||||
ImageConstant.icnScale,
|
||||
ImageConstant.icnSend,
|
||||
ImageConstant.icnShare,
|
||||
ImageConstant.icnSpoonFork,
|
||||
ImageConstant.icnStepsGraph,
|
||||
ImageConstant.icnSteps,
|
||||
ImageConstant.icnSubscribers,
|
||||
ImageConstant.icnSupport,
|
||||
ImageConstant.icnDevTools,
|
||||
ImageConstant.icnVeggies,
|
||||
];
|
||||
|
||||
for (final image in allImageConstants) {
|
||||
expect(image, isNotEmpty);
|
||||
expect(image.contains('assets/') || image.contains(ImageConstant.imagePath), isTrue);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
139
test/core/utils/markdown_utils_test.dart
Normal file
139
test/core/utils/markdown_utils_test.dart
Normal file
@@ -0,0 +1,139 @@
|
||||
import 'package:eatvio_app/core/utils/markdown_utils.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
group('MarkdownUtils', () {
|
||||
test('processMarkdown - обрабатывает простой текст без изменений', () {
|
||||
const text = 'Простой текст без форматирования';
|
||||
final result = MarkdownUtils.processMarkdown(text);
|
||||
expect(result, text);
|
||||
});
|
||||
|
||||
test('processMarkdown - преобразует URL в markdown ссылки', () {
|
||||
const text = 'Посетите https://example.com для получения информации';
|
||||
final result = MarkdownUtils.processMarkdown(text);
|
||||
expect(result, contains('[https://example.com](https://example.com)'));
|
||||
});
|
||||
|
||||
test('processMarkdown - преобразует www URL в markdown ссылки', () {
|
||||
const text = 'Посетите www.example.com для получения информации';
|
||||
final result = MarkdownUtils.processMarkdown(text);
|
||||
expect(result, contains('[www.example.com](https://www.example.com)'));
|
||||
});
|
||||
|
||||
test('processMarkdown - преобразует http URL в markdown ссылки', () {
|
||||
const text = 'Посетите http://example.com для получения информации';
|
||||
final result = MarkdownUtils.processMarkdown(text);
|
||||
expect(result, contains('[http://example.com](http://example.com)'));
|
||||
});
|
||||
|
||||
test('processMarkdown - не преобразует URL внутри существующих markdown ссылок', () {
|
||||
const text = 'Ссылка [https://example.com](https://example.com) уже в формате markdown';
|
||||
final result = MarkdownUtils.processMarkdown(text);
|
||||
// URL не должен быть преобразован дважды
|
||||
expect(result, contains('[https://example.com](https://example.com)'));
|
||||
// Проверяем, что нет дублирования
|
||||
final matches = RegExp(r'\[https://example\.com\]\(https://example\.com\)').allMatches(result);
|
||||
expect(matches.length, 1);
|
||||
});
|
||||
|
||||
test('processMarkdown - исправляет неправильный синтаксис markdown ссылок', () {
|
||||
const text = 'Неправильная ссылка [https://example.com](url)';
|
||||
final result = MarkdownUtils.processMarkdown(text);
|
||||
expect(result, contains('[https://example.com](https://example.com)'));
|
||||
expect(result, isNot(contains('(url)')));
|
||||
});
|
||||
|
||||
test('processMarkdown - исправляет неправильный синтаксис с www', () {
|
||||
const text = 'Неправильная ссылка [www.example.com](url)';
|
||||
final result = MarkdownUtils.processMarkdown(text);
|
||||
expect(result, contains('[www.example.com](https://www.example.com)'));
|
||||
});
|
||||
|
||||
test('processMarkdown - не исправляет ссылки без http или www', () {
|
||||
const text = 'Ссылка [example.com](url) не должна быть исправлена';
|
||||
final result = MarkdownUtils.processMarkdown(text);
|
||||
expect(result, contains('[example.com](url)'));
|
||||
});
|
||||
|
||||
test('processMarkdown - преобразует жирный текст **текст** в <b>текст</b>', () {
|
||||
const text = 'Это **жирный текст** в markdown';
|
||||
final result = MarkdownUtils.processMarkdown(text);
|
||||
expect(result, contains('<b>жирный текст</b>'));
|
||||
expect(result, isNot(contains('**жирный текст**')));
|
||||
});
|
||||
|
||||
test('processMarkdown - обрабатывает несколько жирных фрагментов', () {
|
||||
const text = 'Это **первый** и **второй** жирный текст';
|
||||
final result = MarkdownUtils.processMarkdown(text);
|
||||
expect(result, contains('<b>первый</b>'));
|
||||
expect(result, contains('<b>второй</b>'));
|
||||
});
|
||||
|
||||
test('processMarkdown - обрабатывает URL и жирный текст вместе', () {
|
||||
const text = 'Посетите https://example.com для **важной** информации';
|
||||
final result = MarkdownUtils.processMarkdown(text);
|
||||
expect(result, contains('[https://example.com](https://example.com)'));
|
||||
expect(result, contains('<b>важной</b>'));
|
||||
});
|
||||
|
||||
test('processMarkdown - обрабатывает несколько URL в тексте', () {
|
||||
const text = 'Ссылки: https://example.com и www.test.com';
|
||||
final result = MarkdownUtils.processMarkdown(text);
|
||||
expect(result, contains('[https://example.com](https://example.com)'));
|
||||
expect(result, contains('[www.test.com](https://www.test.com)'));
|
||||
});
|
||||
|
||||
test('processMarkdown - обрабатывает URL с параметрами', () {
|
||||
const text = 'Ссылка https://example.com?param=value&other=test';
|
||||
final result = MarkdownUtils.processMarkdown(text);
|
||||
expect(result, contains('[https://example.com?param=value&other=test](https://example.com?param=value&other=test)'));
|
||||
});
|
||||
|
||||
test('processMarkdown - обрабатывает URL в конце предложения', () {
|
||||
const text = 'Посетите https://example.com.';
|
||||
final result = MarkdownUtils.processMarkdown(text);
|
||||
// Точка включается в URL, так как регулярное выражение захватывает её
|
||||
expect(result, contains('[https://example.com.](https://example.com.)'));
|
||||
});
|
||||
|
||||
test('processMarkdown - обрабатывает URL в скобках', () {
|
||||
const text = 'Ссылка (https://example.com) в скобках';
|
||||
final result = MarkdownUtils.processMarkdown(text);
|
||||
expect(result, contains('[https://example.com](https://example.com)'));
|
||||
});
|
||||
|
||||
test('processMarkdown - обрабатывает пустую строку', () {
|
||||
const text = '';
|
||||
final result = MarkdownUtils.processMarkdown(text);
|
||||
expect(result, '');
|
||||
});
|
||||
|
||||
test('processMarkdown - обрабатывает текст только с пробелами', () {
|
||||
const text = ' ';
|
||||
final result = MarkdownUtils.processMarkdown(text);
|
||||
expect(result, ' ');
|
||||
});
|
||||
|
||||
test('processMarkdown - обрабатывает URL с портом', () {
|
||||
const text = 'Ссылка https://example.com:8080/path';
|
||||
final result = MarkdownUtils.processMarkdown(text);
|
||||
expect(result, contains('[https://example.com:8080/path](https://example.com:8080/path)'));
|
||||
});
|
||||
|
||||
test('processMarkdown - обрабатывает URL с якорем', () {
|
||||
const text = 'Ссылка https://example.com#section';
|
||||
final result = MarkdownUtils.processMarkdown(text);
|
||||
expect(result, contains('[https://example.com#section](https://example.com#section)'));
|
||||
});
|
||||
|
||||
test('processMarkdown - обрабатывает сложный текст с множеством элементов', () {
|
||||
const text = 'Это **важный** текст с https://example.com и **другим** жирным текстом. Также www.test.com';
|
||||
final result = MarkdownUtils.processMarkdown(text);
|
||||
expect(result, contains('<b>важный</b>'));
|
||||
expect(result, contains('<b>другим</b>'));
|
||||
expect(result, contains('[https://example.com](https://example.com)'));
|
||||
expect(result, contains('[www.test.com](https://www.test.com)'));
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -109,5 +109,88 @@ void main() {
|
||||
expect(ValidationUtils.isValidUsername('user name'), isFalse);
|
||||
});
|
||||
});
|
||||
|
||||
group('getValidationErrorMessage', () {
|
||||
test('returns error for null value', () {
|
||||
expect(ValidationUtils.getValidationErrorMessage(null, ValidationType.email), 'Поле не может быть пустым');
|
||||
expect(ValidationUtils.getValidationErrorMessage(null, ValidationType.phone), 'Поле не может быть пустым');
|
||||
expect(ValidationUtils.getValidationErrorMessage(null, ValidationType.url), 'Поле не может быть пустым');
|
||||
});
|
||||
|
||||
test('returns error for empty value', () {
|
||||
expect(ValidationUtils.getValidationErrorMessage('', ValidationType.email), 'Поле не может быть пустым');
|
||||
expect(ValidationUtils.getValidationErrorMessage('', ValidationType.password), 'Поле не может быть пустым');
|
||||
expect(ValidationUtils.getValidationErrorMessage('', ValidationType.username), 'Поле не может быть пустым');
|
||||
});
|
||||
|
||||
test('returns null for valid email', () {
|
||||
expect(ValidationUtils.getValidationErrorMessage('test@example.com', ValidationType.email), isNull);
|
||||
});
|
||||
|
||||
test('returns error for invalid email', () {
|
||||
expect(ValidationUtils.getValidationErrorMessage('invalid', ValidationType.email), 'Введите корректный email');
|
||||
});
|
||||
|
||||
test('returns null for valid phone', () {
|
||||
expect(ValidationUtils.getValidationErrorMessage('+79991234567', ValidationType.phone), isNull);
|
||||
});
|
||||
|
||||
test('returns error for invalid phone', () {
|
||||
expect(ValidationUtils.getValidationErrorMessage('abc', ValidationType.phone), 'Введите корректный номер телефона');
|
||||
});
|
||||
|
||||
test('returns null for valid URL', () {
|
||||
expect(ValidationUtils.getValidationErrorMessage('https://example.com', ValidationType.url), isNull);
|
||||
});
|
||||
|
||||
test('returns error for invalid URL', () {
|
||||
expect(ValidationUtils.getValidationErrorMessage('not a url', ValidationType.url), 'Введите корректный URL');
|
||||
});
|
||||
|
||||
test('returns null for valid password', () {
|
||||
expect(ValidationUtils.getValidationErrorMessage('password123', ValidationType.password), isNull);
|
||||
});
|
||||
|
||||
test('returns error for invalid password', () {
|
||||
expect(ValidationUtils.getValidationErrorMessage('short', ValidationType.password), 'Пароль должен содержать минимум 8 символов, включая буквы и цифры');
|
||||
});
|
||||
|
||||
test('returns null for valid username', () {
|
||||
expect(ValidationUtils.getValidationErrorMessage('user123', ValidationType.username), isNull);
|
||||
});
|
||||
|
||||
test('returns error for invalid username', () {
|
||||
expect(ValidationUtils.getValidationErrorMessage('ab', ValidationType.username), 'Имя пользователя должно содержать от 3 до 20 символов (буквы, цифры, подчеркивания)');
|
||||
});
|
||||
|
||||
test('returns null for valid title', () {
|
||||
expect(ValidationUtils.getValidationErrorMessage('Valid Title', ValidationType.title), isNull);
|
||||
});
|
||||
|
||||
test('returns error for invalid title', () {
|
||||
expect(ValidationUtils.getValidationErrorMessage('', ValidationType.title), 'Поле не может быть пустым');
|
||||
final longTitle = 'a' * (AppConstants.maxTitleLength + 1);
|
||||
expect(ValidationUtils.getValidationErrorMessage(longTitle, ValidationType.title), 'Заголовок не может быть пустым и должен содержать не более ${AppConstants.maxTitleLength} символов');
|
||||
});
|
||||
|
||||
test('returns null for valid description', () {
|
||||
expect(ValidationUtils.getValidationErrorMessage('Valid description', ValidationType.description), isNull);
|
||||
expect(ValidationUtils.getValidationErrorMessage('', ValidationType.description), 'Поле не может быть пустым');
|
||||
});
|
||||
|
||||
test('returns error for invalid description', () {
|
||||
final longDescription = 'a' * (AppConstants.maxDescriptionLength + 1);
|
||||
expect(ValidationUtils.getValidationErrorMessage(longDescription, ValidationType.description), 'Описание не может содержать более ${AppConstants.maxDescriptionLength} символов');
|
||||
});
|
||||
|
||||
test('returns null for valid text', () {
|
||||
expect(ValidationUtils.getValidationErrorMessage('Valid text', ValidationType.text), isNull);
|
||||
});
|
||||
|
||||
test('returns error for invalid text', () {
|
||||
final longText = 'a' * (AppConstants.maxTextLength + 1);
|
||||
expect(ValidationUtils.getValidationErrorMessage(longText, ValidationType.text), 'Текст не может содержать более ${AppConstants.maxTextLength} символов');
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import 'package:eatvio_app/core/app_config.dart';
|
||||
import 'package:eatvio_app/core/app_initializer.dart';
|
||||
import 'package:eatvio_app/core/locator_service.dart';
|
||||
import 'package:eatvio_app/core/token_storage.dart';
|
||||
import 'package:eatvio_app/core/websockets/ws_manager.dart';
|
||||
import 'package:eatvio_app/core/websockets/ws_service.dart';
|
||||
import 'package:eatvio_app/features/chat/models/websocket_info_model.dart';
|
||||
import 'package:flutter_dotenv/flutter_dotenv.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 'ws_manager_test.mocks.dart';
|
||||
|
||||
@@ -17,6 +21,8 @@ void main() {
|
||||
});
|
||||
|
||||
setUp(() async {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
TokenStorage.clearCache();
|
||||
await sl.reset();
|
||||
sl.registerLazySingleton<AppConfig>(() => AppConfig(buildType: AppBuildType.tests)..setBaseUrl('http://localhost'));
|
||||
});
|
||||
@@ -25,7 +31,6 @@ void main() {
|
||||
final wsService = MockWsService();
|
||||
final manager = WsManager(wsService: wsService);
|
||||
|
||||
|
||||
await manager.initialize();
|
||||
|
||||
expect(manager, isNotNull);
|
||||
@@ -86,5 +91,165 @@ void main() {
|
||||
expect(manager.client, isNull);
|
||||
expect(manager.userId, isNull);
|
||||
});
|
||||
|
||||
test('initPrivateDirectChatChannel - создает канал для прямого чата', () async {
|
||||
final wsService = MockWsService();
|
||||
final manager = WsManager(wsService: wsService);
|
||||
const chatId = 'chat-123';
|
||||
const token = 'test-token';
|
||||
|
||||
await TokenStorage.saveTokenInStorage(token);
|
||||
|
||||
// initPrivateDirectChatChannel требует, чтобы client был инициализирован
|
||||
// Но так как мы не можем легко мокировать PusherChannelsClient,
|
||||
// этот тест проверяет, что метод не выбрасывает исключение
|
||||
// когда client равен null
|
||||
expect(() => manager.initPrivateDirectChatChannel(chatId), returnsNormally);
|
||||
});
|
||||
|
||||
test('initialize - обрабатывает ошибку при отсутствии токена', () async {
|
||||
final wsService = MockWsService();
|
||||
final manager = WsManager(wsService: wsService);
|
||||
|
||||
// Убеждаемся, что токена нет
|
||||
await TokenStorage.removeTokenInStorage();
|
||||
|
||||
// initialize должен обработать ошибку gracefully
|
||||
await manager.initialize();
|
||||
|
||||
expect(manager.client, isNull);
|
||||
});
|
||||
|
||||
test('initialize - обрабатывает ошибку при ошибке getWebsocket', () async {
|
||||
final wsService = MockWsService();
|
||||
final manager = WsManager(wsService: wsService);
|
||||
const token = 'test-token';
|
||||
|
||||
await TokenStorage.saveTokenInStorage(token);
|
||||
when(wsService.getWebsocket()).thenThrow(Exception('Network error'));
|
||||
|
||||
// initialize должен обработать ошибку gracefully
|
||||
await manager.initialize();
|
||||
|
||||
expect(manager.client, isNull);
|
||||
});
|
||||
|
||||
test('initialize - устанавливает userId из WebsocketInfoModel', () async {
|
||||
final wsService = MockWsService();
|
||||
final manager = WsManager(wsService: wsService);
|
||||
const token = 'test-token';
|
||||
const userId = 42;
|
||||
|
||||
await TokenStorage.saveTokenInStorage(token);
|
||||
when(
|
||||
wsService.getWebsocket(),
|
||||
).thenAnswer((_) async => const WebsocketInfoModel(key: 'test-key', host: 'test-host', port: '443', cluster: 'test-cluster', userId: userId));
|
||||
|
||||
// initialize должен установить userId, но так как мы не можем
|
||||
// полностью мокировать PusherChannelsClient, этот тест проверяет,
|
||||
// что метод не выбрасывает исключение
|
||||
await manager.initialize();
|
||||
|
||||
// userId может быть установлен только если client успешно создан
|
||||
// В данном случае, так как мы не можем полностью мокировать client,
|
||||
// мы просто проверяем, что метод не выбрасывает исключение
|
||||
expect(manager, isNotNull);
|
||||
});
|
||||
|
||||
test('clear - отписывается от всех каналов', () async {
|
||||
final wsService = MockWsService();
|
||||
final manager = WsManager(wsService: wsService);
|
||||
|
||||
manager.userId = 1;
|
||||
|
||||
// clear должен отписаться от всех каналов
|
||||
await manager.clear();
|
||||
|
||||
expect(manager.client, isNull);
|
||||
expect(manager.userId, isNull);
|
||||
});
|
||||
|
||||
test('initialize - не инициализирует повторно если client уже существует', () async {
|
||||
final wsService = MockWsService();
|
||||
final manager = WsManager(wsService: wsService);
|
||||
const token = 'test-token';
|
||||
|
||||
await TokenStorage.saveTokenInStorage(token);
|
||||
when(
|
||||
wsService.getWebsocket(),
|
||||
).thenAnswer((_) async => const WebsocketInfoModel(key: 'test-key', host: 'test-host', port: '443', cluster: 'test-cluster', userId: 1));
|
||||
|
||||
// Первый вызов initialize
|
||||
await manager.initialize();
|
||||
|
||||
// Второй вызов initialize не должен вызывать getWebsocket снова
|
||||
// если client уже инициализирован
|
||||
final initialClient = manager.client;
|
||||
await manager.initialize();
|
||||
|
||||
// client должен остаться тем же (или null, если инициализация не удалась)
|
||||
expect(manager.client, initialClient);
|
||||
});
|
||||
|
||||
test('subscribeDirectChatChannel - подписывается на канал когда он существует', () {
|
||||
final wsService = MockWsService();
|
||||
final manager = WsManager(wsService: wsService);
|
||||
|
||||
// Когда канал null, метод не должен выбрасывать исключение
|
||||
manager.subscribeDirectChatChannel();
|
||||
|
||||
expect(manager, isNotNull);
|
||||
});
|
||||
|
||||
test('unsubscribeDirectChatChannel - отписывается от канала когда он существует', () {
|
||||
final wsService = MockWsService();
|
||||
final manager = WsManager(wsService: wsService);
|
||||
|
||||
// Когда канал null, метод не должен выбрасывать исключение
|
||||
manager.unsubscribeDirectChatChannel();
|
||||
|
||||
expect(manager, isNotNull);
|
||||
});
|
||||
|
||||
test('newMessagesStream - возвращает null когда канал не инициализирован', () {
|
||||
final wsService = MockWsService();
|
||||
final manager = WsManager(wsService: wsService);
|
||||
|
||||
expect(manager.newMessagesStream, isNull);
|
||||
});
|
||||
|
||||
test('newNotificationsStream - возвращает null когда канал не инициализирован', () {
|
||||
final wsService = MockWsService();
|
||||
final manager = WsManager(wsService: wsService);
|
||||
|
||||
expect(manager.newNotificationsStream, isNull);
|
||||
});
|
||||
|
||||
test('directChatStream - возвращает null когда канал не инициализирован', () {
|
||||
final wsService = MockWsService();
|
||||
final manager = WsManager(wsService: wsService);
|
||||
|
||||
expect(manager.directChatStream, isNull);
|
||||
});
|
||||
|
||||
test('clientTypingStream - возвращает null когда канал не инициализирован', () {
|
||||
final wsService = MockWsService();
|
||||
final manager = WsManager(wsService: wsService);
|
||||
|
||||
expect(manager.clientTypingStream, isNull);
|
||||
});
|
||||
|
||||
test('initialize - обрабатывает случай когда TokenStorage.haveTokenInStorage возвращает false', () async {
|
||||
final wsService = MockWsService();
|
||||
final manager = WsManager(wsService: wsService);
|
||||
|
||||
// Убеждаемся, что токена нет
|
||||
await TokenStorage.removeTokenInStorage();
|
||||
|
||||
// initialize должен обработать отсутствие токена gracefully
|
||||
await manager.initialize();
|
||||
|
||||
expect(manager.client, isNull);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ void main() {
|
||||
const MarketingBottomSheet(),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(MarketingBottomSheet), findsOneWidget);
|
||||
});
|
||||
@@ -59,6 +60,7 @@ void main() {
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pump();
|
||||
await tester.tap(find.text('Show'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ void main() {
|
||||
|
||||
testWidgets('returns SizedBox.shrink when option is null', (tester) async {
|
||||
await tester.pumpWidget(createTestWidget(const OptionWidget(option: null, duration: 7)));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(OptionWidget), findsOneWidget);
|
||||
expect(find.byType(SizedBox), findsWidgets);
|
||||
@@ -33,6 +34,7 @@ void main() {
|
||||
const option = PacketOptions(items: ['Item 1', 'Item 2'], price: 1000.0, short: 'Short', description: 'Description', hasPersonalDiet: false);
|
||||
|
||||
await tester.pumpWidget(createTestWidget(const OptionWidget(option: option, duration: 7)));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(OptionWidget), findsOneWidget);
|
||||
expect(find.text('Short'), findsOneWidget);
|
||||
@@ -43,6 +45,7 @@ void main() {
|
||||
const option = PacketOptions(items: ['Item 1'], price: 1000.0, short: 'Short', description: 'Description', hasPersonalDiet: true);
|
||||
|
||||
await tester.pumpWidget(createTestWidget(const OptionWidget(option: option, duration: 7)));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(OptionWidget), findsOneWidget);
|
||||
});
|
||||
@@ -51,6 +54,7 @@ void main() {
|
||||
const option = PacketOptions(items: ['Item 1'], price: 1000.0, short: 'Short', description: 'Description', hasPersonalDiet: false);
|
||||
|
||||
await tester.pumpWidget(createTestWidget(const OptionWidget(option: option, duration: 7, disableDuration: true)));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(OptionWidget), findsOneWidget);
|
||||
});
|
||||
|
||||
@@ -31,6 +31,7 @@ void main() {
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pump();
|
||||
await tester.tap(find.text('Show Error'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
@@ -68,6 +69,7 @@ void main() {
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pump();
|
||||
await tester.tap(find.text('Show Error'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
@@ -103,6 +105,7 @@ void main() {
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pump();
|
||||
await tester.tap(find.text('Show Error'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
@@ -134,6 +137,7 @@ void main() {
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pump();
|
||||
await tester.tap(find.text('Show Error'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
|
||||
167
test/features/auth/models/oauth_models_test.dart
Normal file
167
test/features/auth/models/oauth_models_test.dart
Normal file
@@ -0,0 +1,167 @@
|
||||
import 'package:eatvio_app/features/auth/models/oauth_verification_confirm_request.dart';
|
||||
import 'package:eatvio_app/features/auth/models/oauth_verification_confirm_response.dart';
|
||||
import 'package:eatvio_app/features/auth/models/oauth_verification_request_response.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
group('OAuthVerificationRequestResponse', () {
|
||||
test('fromJson parses JSON correctly', () {
|
||||
final json = {'email': 'test@example.com', 'status': 'pending'};
|
||||
|
||||
final response = OAuthVerificationRequestResponse.fromJson(json);
|
||||
|
||||
expect(response.email, 'test@example.com');
|
||||
expect(response.status, 'pending');
|
||||
});
|
||||
|
||||
test('toJson serializes correctly', () {
|
||||
const response = OAuthVerificationRequestResponse(email: 'test@example.com', status: 'pending');
|
||||
|
||||
final json = response.toJson();
|
||||
|
||||
expect(json['email'], 'test@example.com');
|
||||
expect(json['status'], 'pending');
|
||||
});
|
||||
});
|
||||
|
||||
group('OAuthVerificationConfirmRequest', () {
|
||||
test('fromJson parses JSON correctly', () {
|
||||
final json = {
|
||||
'access_token': 'access123',
|
||||
'refresh_token': 'refresh123',
|
||||
'client_id': 'client123',
|
||||
'provider': 'google',
|
||||
'id': 'user123',
|
||||
'email': 'test@example.com',
|
||||
'phone': '+1234567890',
|
||||
'code': 'verification_code',
|
||||
'verification_type': 'email',
|
||||
'first_name': 'John',
|
||||
'last_name': 'Doe',
|
||||
'date_of_birth': '1990-01-01',
|
||||
'gender': 'male',
|
||||
'weight': 75,
|
||||
'height': 180,
|
||||
};
|
||||
|
||||
final request = OAuthVerificationConfirmRequest.fromJson(json);
|
||||
|
||||
expect(request.accessToken, 'access123');
|
||||
expect(request.refreshToken, 'refresh123');
|
||||
expect(request.clientId, 'client123');
|
||||
expect(request.provider, 'google');
|
||||
expect(request.id, 'user123');
|
||||
expect(request.email, 'test@example.com');
|
||||
expect(request.phone, '+1234567890');
|
||||
expect(request.code, 'verification_code');
|
||||
expect(request.verificationType, 'email');
|
||||
expect(request.firstName, 'John');
|
||||
expect(request.lastName, 'Doe');
|
||||
expect(request.dateOfBirth, '1990-01-01');
|
||||
expect(request.gender, 'male');
|
||||
expect(request.weight, 75);
|
||||
expect(request.height, 180);
|
||||
});
|
||||
|
||||
test('fromJson handles optional fields', () {
|
||||
final json = {
|
||||
'access_token': 'access123',
|
||||
'refresh_token': 'refresh123',
|
||||
'client_id': 'client123',
|
||||
'provider': 'google',
|
||||
'id': 'user123',
|
||||
'code': 'verification_code',
|
||||
'verification_type': 'email',
|
||||
'first_name': 'John',
|
||||
'last_name': 'Doe',
|
||||
'date_of_birth': '1990-01-01',
|
||||
'gender': 'male',
|
||||
'weight': 75,
|
||||
'height': 180,
|
||||
};
|
||||
|
||||
final request = OAuthVerificationConfirmRequest.fromJson(json);
|
||||
|
||||
expect(request.email, isNull);
|
||||
expect(request.phone, isNull);
|
||||
});
|
||||
|
||||
test('toJson serializes correctly', () {
|
||||
const request = OAuthVerificationConfirmRequest(
|
||||
accessToken: 'access123',
|
||||
refreshToken: 'refresh123',
|
||||
clientId: 'client123',
|
||||
provider: 'google',
|
||||
id: 'user123',
|
||||
email: 'test@example.com',
|
||||
phone: '+1234567890',
|
||||
code: 'verification_code',
|
||||
verificationType: 'email',
|
||||
firstName: 'John',
|
||||
lastName: 'Doe',
|
||||
dateOfBirth: '1990-01-01',
|
||||
gender: 'male',
|
||||
weight: 75,
|
||||
height: 180,
|
||||
);
|
||||
|
||||
final json = request.toJson();
|
||||
|
||||
expect(json['access_token'], 'access123');
|
||||
expect(json['refresh_token'], 'refresh123');
|
||||
expect(json['client_id'], 'client123');
|
||||
expect(json['provider'], 'google');
|
||||
expect(json['id'], 'user123');
|
||||
expect(json['email'], 'test@example.com');
|
||||
expect(json['phone'], '+1234567890');
|
||||
expect(json['code'], 'verification_code');
|
||||
expect(json['verification_type'], 'email');
|
||||
expect(json['first_name'], 'John');
|
||||
expect(json['last_name'], 'Doe');
|
||||
expect(json['date_of_birth'], '1990-01-01');
|
||||
expect(json['gender'], 'male');
|
||||
expect(json['weight'], 75);
|
||||
expect(json['height'], 180);
|
||||
});
|
||||
});
|
||||
|
||||
group('OAuthVerificationConfirmResponse', () {
|
||||
test('fromJson parses JSON correctly', () {
|
||||
final json = {
|
||||
'success': true,
|
||||
'message': 'Verification successful',
|
||||
'token': 'auth_token_123',
|
||||
'user': {'id': 1, 'name': 'John Doe'},
|
||||
'is_new_user': true,
|
||||
};
|
||||
|
||||
final response = OAuthVerificationConfirmResponse.fromJson(json);
|
||||
|
||||
expect(response.success, isTrue);
|
||||
expect(response.message, 'Verification successful');
|
||||
expect(response.token, 'auth_token_123');
|
||||
expect(response.user['id'], 1);
|
||||
expect(response.user['name'], 'John Doe');
|
||||
expect(response.isNewUser, isTrue);
|
||||
});
|
||||
|
||||
test('toJson serializes correctly', () {
|
||||
const response = OAuthVerificationConfirmResponse(
|
||||
success: true,
|
||||
message: 'Verification successful',
|
||||
token: 'auth_token_123',
|
||||
user: {'id': 1, 'name': 'John Doe'},
|
||||
isNewUser: false,
|
||||
);
|
||||
|
||||
final json = response.toJson();
|
||||
|
||||
expect(json['success'], isTrue);
|
||||
expect(json['message'], 'Verification successful');
|
||||
expect(json['token'], 'auth_token_123');
|
||||
expect(json['user']['id'], 1);
|
||||
expect(json['user']['name'], 'John Doe');
|
||||
expect(json['is_new_user'], isFalse);
|
||||
});
|
||||
});
|
||||
}
|
||||
29
test/features/auth/models/token_model_test.dart
Normal file
29
test/features/auth/models/token_model_test.dart
Normal file
@@ -0,0 +1,29 @@
|
||||
import 'package:eatvio_app/features/auth/models/token_model.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
group('TokenModel', () {
|
||||
test('fromJson parses token correctly', () {
|
||||
final json = {'token': 'test-token-12345'};
|
||||
|
||||
final model = TokenModel.fromJson(json);
|
||||
|
||||
expect(model.token, 'test-token-12345');
|
||||
});
|
||||
|
||||
test('toJson serializes token correctly', () {
|
||||
const model = TokenModel(token: 'test-token-12345');
|
||||
|
||||
final json = model.toJson();
|
||||
|
||||
expect(json['token'], 'test-token-12345');
|
||||
});
|
||||
|
||||
test('creates instance with token', () {
|
||||
const model = TokenModel(token: 'another-token');
|
||||
|
||||
expect(model.token, 'another-token');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -48,6 +48,56 @@ void main() {
|
||||
expect(model.login, 'test@yandex.ru');
|
||||
expect(model.defaultPhone.number, '+79001234567');
|
||||
});
|
||||
|
||||
test('fromJson handles null sex field', () {
|
||||
final json = {
|
||||
'id': '123',
|
||||
'login': 'test@yandex.ru',
|
||||
'birthday': '1990-01-01',
|
||||
'client_id': 'client-123',
|
||||
'display_name': 'Display Name',
|
||||
'real_name': 'Real Name',
|
||||
'first_name': 'First',
|
||||
'last_name': 'Last',
|
||||
'sex': null,
|
||||
'default_email': 'test@yandex.ru',
|
||||
'default_phone': {
|
||||
'id': 1,
|
||||
'number': '+79001234567',
|
||||
},
|
||||
};
|
||||
|
||||
final model = YandexProfileModel.fromJson(json);
|
||||
|
||||
expect(model.sex, isNull);
|
||||
});
|
||||
|
||||
test('toJson serializes YandexProfileModel correctly', () {
|
||||
final phoneData = YandexPhoneData(id: 1, number: '+79001234567');
|
||||
final model = YandexProfileModel(
|
||||
id: '123',
|
||||
login: 'test@yandex.ru',
|
||||
birthday: '1990-01-01',
|
||||
clientId: 'client-123',
|
||||
displayName: 'Display Name',
|
||||
realName: 'Real Name',
|
||||
firstName: 'First',
|
||||
lastName: 'Last',
|
||||
sex: 'male',
|
||||
defaultEmail: 'test@yandex.ru',
|
||||
defaultPhone: phoneData,
|
||||
);
|
||||
|
||||
final json = model.toJson();
|
||||
|
||||
expect(json['id'], '123');
|
||||
expect(json['login'], 'test@yandex.ru');
|
||||
expect(json['client_id'], 'client-123');
|
||||
expect(json['first_name'], 'First');
|
||||
expect(json['last_name'], 'Last');
|
||||
expect(json['sex'], 'male');
|
||||
expect(json.containsKey('default_phone'), isTrue);
|
||||
});
|
||||
});
|
||||
|
||||
group('YandexPhoneData', () {
|
||||
@@ -69,6 +119,15 @@ void main() {
|
||||
expect(phoneData.id, 1);
|
||||
expect(phoneData.number, '+79001234567');
|
||||
});
|
||||
|
||||
test('toJson serializes YandexPhoneData correctly', () {
|
||||
final phoneData = YandexPhoneData(id: 1, number: '+79001234567');
|
||||
|
||||
final json = phoneData.toJson();
|
||||
|
||||
expect(json['id'], 1);
|
||||
expect(json['number'], '+79001234567');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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/features/auth/models/token_model.dart';
|
||||
import 'package:eatvio_app/features/auth/presentation/bloc/auth_cubit/auth_cubit.dart';
|
||||
import 'package:eatvio_app/features/auth/presentation/bloc/auth_cubit/auth_state.dart';
|
||||
@@ -7,6 +8,7 @@ import 'package:eatvio_app/features/profile/repositories/profile_repository.dart
|
||||
import 'package:flutter/services.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 '../../../../fixtures/doubles/auth_doubles.dart';
|
||||
@@ -134,5 +136,47 @@ void main() {
|
||||
{'email': 'second@test.com', 'password': 'password2'},
|
||||
]);
|
||||
});
|
||||
|
||||
test('registerAnonymousPushToken - регистрирует токен если не был отправлен', () async {
|
||||
authCubit = AuthCubit(authRepository: authRepository, prefs: mockPrefs, profileRepository: profileRepository, firebaseApi: mockFirebaseApi);
|
||||
when(mockPrefs.getBool(PrefsKeys.hasAnonymousTokenBeenSent)).thenReturn(false);
|
||||
when(mockFirebaseApi.registerAnonymousPushToken()).thenAnswer((_) async => {});
|
||||
when(mockPrefs.setBool(PrefsKeys.hasAnonymousTokenBeenSent, true)).thenAnswer((_) async => {});
|
||||
|
||||
await authCubit!.registerAnonymousPushToken();
|
||||
|
||||
verify(mockFirebaseApi.registerAnonymousPushToken()).called(1);
|
||||
verify(mockPrefs.setBool(PrefsKeys.hasAnonymousTokenBeenSent, true)).called(1);
|
||||
});
|
||||
|
||||
test('registerAnonymousPushToken - не регистрирует токен если уже был отправлен', () async {
|
||||
authCubit = AuthCubit(authRepository: authRepository, prefs: mockPrefs, profileRepository: profileRepository, firebaseApi: mockFirebaseApi);
|
||||
when(mockPrefs.getBool(PrefsKeys.hasAnonymousTokenBeenSent)).thenReturn(true);
|
||||
|
||||
await authCubit!.registerAnonymousPushToken();
|
||||
|
||||
verifyNever(mockFirebaseApi.registerAnonymousPushToken());
|
||||
});
|
||||
|
||||
test('registerPushToken - регистрирует push токен', () async {
|
||||
authCubit = AuthCubit(authRepository: authRepository, prefs: mockPrefs, profileRepository: profileRepository, firebaseApi: mockFirebaseApi);
|
||||
when(mockFirebaseApi.registerPushToken()).thenAnswer((_) async => {});
|
||||
|
||||
await authCubit!.registerPushToken();
|
||||
|
||||
verify(mockFirebaseApi.registerPushToken()).called(1);
|
||||
});
|
||||
|
||||
test('registerAnonymousPushToken - обрабатывает null значение hasAnonymousTokenBeenSent', () async {
|
||||
authCubit = AuthCubit(authRepository: authRepository, prefs: mockPrefs, profileRepository: profileRepository, firebaseApi: mockFirebaseApi);
|
||||
when(mockPrefs.getBool(PrefsKeys.hasAnonymousTokenBeenSent)).thenReturn(null);
|
||||
when(mockFirebaseApi.registerAnonymousPushToken()).thenAnswer((_) async => {});
|
||||
when(mockPrefs.setBool(PrefsKeys.hasAnonymousTokenBeenSent, true)).thenAnswer((_) async => {});
|
||||
|
||||
await authCubit!.registerAnonymousPushToken();
|
||||
|
||||
verify(mockFirebaseApi.registerAnonymousPushToken()).called(1);
|
||||
verify(mockPrefs.setBool(PrefsKeys.hasAnonymousTokenBeenSent, true)).called(1);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
132
test/features/barcode/models/barcode_product_model_test.dart
Normal file
132
test/features/barcode/models/barcode_product_model_test.dart
Normal file
@@ -0,0 +1,132 @@
|
||||
import 'package:eatvio_app/features/barcode/models/barcode_product_model.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
group('BarcodeProductModel', () {
|
||||
test('fromJson parses all fields correctly', () {
|
||||
final json = {
|
||||
'GTIN': '1234567890123',
|
||||
'Товарный знак (ru)': 'Test Brand',
|
||||
'Описание (ru)': 'Test Description',
|
||||
'Количество/вес': '100g',
|
||||
'Рынок сбыта': 'Russia',
|
||||
'Калорийность': 250.5,
|
||||
'Белки': 10.0,
|
||||
'Жиры': 5.0,
|
||||
'Углеводы': 30.0,
|
||||
};
|
||||
|
||||
final model = BarcodeProductModel.fromJson(json);
|
||||
|
||||
expect(model.GTIN, '1234567890123');
|
||||
expect(model.brandName, 'Test Brand');
|
||||
expect(model.description, 'Test Description');
|
||||
expect(model.quantity, '100g');
|
||||
expect(model.market, 'Russia');
|
||||
expect(model.calories, 250.5);
|
||||
expect(model.proteins, 10.0);
|
||||
expect(model.fats, 5.0);
|
||||
expect(model.carbohydrates, 30.0);
|
||||
});
|
||||
|
||||
test('fromJson handles missing optional fields', () {
|
||||
final json = {
|
||||
'GTIN': '1234567890123',
|
||||
};
|
||||
|
||||
final model = BarcodeProductModel.fromJson(json);
|
||||
|
||||
expect(model.GTIN, '1234567890123');
|
||||
expect(model.brandName, isNull);
|
||||
expect(model.description, isNull);
|
||||
expect(model.quantity, isNull);
|
||||
expect(model.market, isNull);
|
||||
expect(model.calories, isNull);
|
||||
expect(model.proteins, isNull);
|
||||
expect(model.fats, isNull);
|
||||
expect(model.carbohydrates, isNull);
|
||||
});
|
||||
|
||||
test('toJson serializes all fields correctly', () {
|
||||
const model = BarcodeProductModel(
|
||||
GTIN: '1234567890123',
|
||||
brandName: 'Test Brand',
|
||||
description: 'Test Description',
|
||||
quantity: '100g',
|
||||
market: 'Russia',
|
||||
calories: 250.5,
|
||||
proteins: 10.0,
|
||||
fats: 5.0,
|
||||
carbohydrates: 30.0,
|
||||
);
|
||||
|
||||
final json = model.toJson();
|
||||
|
||||
expect(json['GTIN'], '1234567890123');
|
||||
expect(json['Товарный знак (ru)'], 'Test Brand');
|
||||
expect(json['Описание (ru)'], 'Test Description');
|
||||
expect(json['Количество/вес'], '100g');
|
||||
expect(json['Рынок сбыта'], 'Russia');
|
||||
expect(json['Калорийность'], 250.5);
|
||||
expect(json['Белки'], 10.0);
|
||||
expect(json['Жиры'], 5.0);
|
||||
expect(json['Углеводы'], 30.0);
|
||||
});
|
||||
});
|
||||
|
||||
group('BarcodeTaskStatusModel', () {
|
||||
test('fromJson parses all fields correctly', () {
|
||||
final json = {
|
||||
'status': 'completed',
|
||||
'task_id': 'task-123',
|
||||
'data': {
|
||||
'GTIN': '1234567890123',
|
||||
'Товарный знак (ru)': 'Test Brand',
|
||||
},
|
||||
};
|
||||
|
||||
final model = BarcodeTaskStatusModel.fromJson(json);
|
||||
|
||||
expect(model.status, 'completed');
|
||||
expect(model.taskId, 'task-123');
|
||||
expect(model.data, isNotNull);
|
||||
expect(model.data?.GTIN, '1234567890123');
|
||||
expect(model.data?.brandName, 'Test Brand');
|
||||
});
|
||||
|
||||
test('fromJson handles missing optional fields', () {
|
||||
final json = {
|
||||
'status': 'pending',
|
||||
};
|
||||
|
||||
final model = BarcodeTaskStatusModel.fromJson(json);
|
||||
|
||||
expect(model.status, 'pending');
|
||||
expect(model.taskId, isNull);
|
||||
expect(model.data, isNull);
|
||||
});
|
||||
|
||||
test('toJson serializes all fields correctly', () {
|
||||
const productModel = BarcodeProductModel(GTIN: '1234567890123', brandName: 'Test Brand');
|
||||
const model = BarcodeTaskStatusModel(
|
||||
status: 'completed',
|
||||
taskId: 'task-123',
|
||||
data: productModel,
|
||||
);
|
||||
|
||||
final json = model.toJson();
|
||||
|
||||
expect(json['status'], 'completed');
|
||||
expect(json['task_id'], 'task-123');
|
||||
expect(json['data'], isNotNull);
|
||||
if (json['data'] is Map) {
|
||||
final dataJson = json['data'] as Map<String, dynamic>;
|
||||
expect(dataJson['GTIN'], '1234567890123');
|
||||
} else {
|
||||
// If data is already a model, check it directly
|
||||
expect(model.data?.GTIN, '1234567890123');
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -60,6 +60,32 @@ void main() {
|
||||
expect(state.isLoading, isFalse);
|
||||
expect(state.error, isNull);
|
||||
});
|
||||
|
||||
test('toJson serializes BlogPostState correctly', () {
|
||||
final state = BlogPostState(
|
||||
blogPostModel: null,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
);
|
||||
|
||||
final json = state.toJson();
|
||||
|
||||
expect(json['isLoading'], isFalse);
|
||||
expect(json['error'], isNull);
|
||||
expect(json.containsKey('blogPostModel'), isTrue);
|
||||
});
|
||||
|
||||
test('copyWith creates new instance with updated values', () {
|
||||
final original = BlogPostState();
|
||||
final updated = original.copyWith(
|
||||
isLoading: true,
|
||||
error: 'Test error',
|
||||
);
|
||||
|
||||
expect(updated.isLoading, isTrue);
|
||||
expect(updated.error, 'Test error');
|
||||
expect(updated.blogPostModel, original.blogPostModel);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -63,5 +63,167 @@ void main() {
|
||||
),
|
||||
).called(1);
|
||||
});
|
||||
|
||||
test('createBlogPost - обрабатывает пустые поля', () async {
|
||||
when(() => mockService.createBlogPost(formData: any(named: 'formData'))).thenAnswer((_) async => {});
|
||||
|
||||
await repository.createBlogPost(null, null, null, null);
|
||||
|
||||
verify(() => mockService.createBlogPost(formData: any(named: 'formData'))).called(1);
|
||||
});
|
||||
|
||||
test('createBlogPost - обрабатывает только title', () async {
|
||||
const title = 'Test Title';
|
||||
|
||||
when(() => mockService.createBlogPost(formData: any(named: 'formData'))).thenAnswer((_) async => {});
|
||||
|
||||
await repository.createBlogPost(title, null, null, null);
|
||||
|
||||
verify(() => mockService.createBlogPost(formData: any(named: 'formData'))).called(1);
|
||||
});
|
||||
|
||||
test('createBlogPost - обрабатывает только videoUrl', () async {
|
||||
const videoUrl = 'https://example.com/video.mp4';
|
||||
|
||||
when(() => mockService.createBlogPost(formData: any(named: 'formData'))).thenAnswer((_) async => {});
|
||||
|
||||
await repository.createBlogPost(null, videoUrl, null, null);
|
||||
|
||||
verify(() => mockService.createBlogPost(formData: any(named: 'formData'))).called(1);
|
||||
});
|
||||
|
||||
test('createBlogPost - обрабатывает только description', () async {
|
||||
const description = 'Test Description';
|
||||
|
||||
when(() => mockService.createBlogPost(formData: any(named: 'formData'))).thenAnswer((_) async => {});
|
||||
|
||||
await repository.createBlogPost(null, null, description, null);
|
||||
|
||||
verify(() => mockService.createBlogPost(formData: any(named: 'formData'))).called(1);
|
||||
});
|
||||
|
||||
test('updateBlogPost - обрабатывает все поля', () async {
|
||||
const id = 1;
|
||||
const title = 'Updated Title';
|
||||
const videoUrl = 'https://example.com/video.mp4';
|
||||
const description = 'Updated Description';
|
||||
|
||||
when(
|
||||
() => mockService.updateBlogPost(
|
||||
id: id,
|
||||
formData: any(named: 'formData'),
|
||||
),
|
||||
).thenAnswer((_) async => {});
|
||||
|
||||
await repository.updateBlogPost(id, title, videoUrl, description, null, null);
|
||||
|
||||
verify(
|
||||
() => mockService.updateBlogPost(
|
||||
id: id,
|
||||
formData: any(named: 'formData'),
|
||||
),
|
||||
).called(1);
|
||||
});
|
||||
|
||||
test('updateBlogPost - обрабатывает пустые поля', () async {
|
||||
const id = 1;
|
||||
|
||||
when(
|
||||
() => mockService.updateBlogPost(
|
||||
id: id,
|
||||
formData: any(named: 'formData'),
|
||||
),
|
||||
).thenAnswer((_) async => {});
|
||||
|
||||
await repository.updateBlogPost(id, null, null, null, null, null);
|
||||
|
||||
verify(
|
||||
() => mockService.updateBlogPost(
|
||||
id: id,
|
||||
formData: any(named: 'formData'),
|
||||
),
|
||||
).called(1);
|
||||
});
|
||||
|
||||
test('createBlogPost - обрабатывает пустые поля', () async {
|
||||
when(() => mockService.createBlogPost(formData: any(named: 'formData'))).thenAnswer((_) async => {});
|
||||
|
||||
await repository.createBlogPost(null, null, null, null);
|
||||
|
||||
verify(() => mockService.createBlogPost(formData: any(named: 'formData'))).called(1);
|
||||
});
|
||||
|
||||
test('createBlogPost - обрабатывает только title', () async {
|
||||
const title = 'Test Title';
|
||||
|
||||
when(() => mockService.createBlogPost(formData: any(named: 'formData'))).thenAnswer((_) async => {});
|
||||
|
||||
await repository.createBlogPost(title, null, null, null);
|
||||
|
||||
verify(() => mockService.createBlogPost(formData: any(named: 'formData'))).called(1);
|
||||
});
|
||||
|
||||
test('createBlogPost - обрабатывает только videoUrl', () async {
|
||||
const videoUrl = 'https://example.com/video.mp4';
|
||||
|
||||
when(() => mockService.createBlogPost(formData: any(named: 'formData'))).thenAnswer((_) async => {});
|
||||
|
||||
await repository.createBlogPost(null, videoUrl, null, null);
|
||||
|
||||
verify(() => mockService.createBlogPost(formData: any(named: 'formData'))).called(1);
|
||||
});
|
||||
|
||||
test('createBlogPost - обрабатывает только description', () async {
|
||||
const description = 'Test Description';
|
||||
|
||||
when(() => mockService.createBlogPost(formData: any(named: 'formData'))).thenAnswer((_) async => {});
|
||||
|
||||
await repository.createBlogPost(null, null, description, null);
|
||||
|
||||
verify(() => mockService.createBlogPost(formData: any(named: 'formData'))).called(1);
|
||||
});
|
||||
|
||||
test('updateBlogPost - обрабатывает все поля', () async {
|
||||
const id = 1;
|
||||
const title = 'Updated Title';
|
||||
const videoUrl = 'https://example.com/video.mp4';
|
||||
const description = 'Updated Description';
|
||||
|
||||
when(
|
||||
() => mockService.updateBlogPost(
|
||||
id: id,
|
||||
formData: any(named: 'formData'),
|
||||
),
|
||||
).thenAnswer((_) async => {});
|
||||
|
||||
await repository.updateBlogPost(id, title, videoUrl, description, null, null);
|
||||
|
||||
verify(
|
||||
() => mockService.updateBlogPost(
|
||||
id: id,
|
||||
formData: any(named: 'formData'),
|
||||
),
|
||||
).called(1);
|
||||
});
|
||||
|
||||
test('updateBlogPost - обрабатывает пустые поля', () async {
|
||||
const id = 1;
|
||||
|
||||
when(
|
||||
() => mockService.updateBlogPost(
|
||||
id: id,
|
||||
formData: any(named: 'formData'),
|
||||
),
|
||||
).thenAnswer((_) async => {});
|
||||
|
||||
await repository.updateBlogPost(id, null, null, null, null, null);
|
||||
|
||||
verify(
|
||||
() => mockService.updateBlogPost(
|
||||
id: id,
|
||||
formData: any(named: 'formData'),
|
||||
),
|
||||
).called(1);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -317,6 +317,231 @@ void main() {
|
||||
|
||||
expect(loadedState, isNotNull);
|
||||
});
|
||||
|
||||
test('getMyProfile - обработка неправильного формата даты рождения', () async {
|
||||
cubit = ProfileAvatarCubit(profileRepository: mockRepository);
|
||||
|
||||
const testProfile = ProfileModel(
|
||||
id: 1,
|
||||
slug: 'test-user',
|
||||
name: 'Test User',
|
||||
email: 'test@example.com',
|
||||
phoneConfirmationState: 'confirmed',
|
||||
isPublicProfile: true,
|
||||
isBlockedByMe: false,
|
||||
isBlockedByAuthor: false,
|
||||
isBlockedByUser: false,
|
||||
isEntitySubscribedByMe: false,
|
||||
profilePhotoUrl: 'https://example.com/icon.jpg',
|
||||
roles: [],
|
||||
badges: [],
|
||||
subscriptions: [],
|
||||
subscribers: [],
|
||||
);
|
||||
|
||||
// Неправильный формат даты (не 3 части)
|
||||
const testProfileDetails = ProfileDetailsModel(
|
||||
firstName: 'Test',
|
||||
lastName: 'User',
|
||||
birthdate: 'invalid-date',
|
||||
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: 'No allergies',
|
||||
aboutTakingPils: 'No pills',
|
||||
aboutSportHobbies: 'Running',
|
||||
favoriteFoods: 'Fruits',
|
||||
unfavoriteFoods: 'Fast food',
|
||||
additionalInfo: 'Additional info',
|
||||
isMoodFillable: true,
|
||||
isBedtimeFillable: true,
|
||||
activityCoefficient: 1.5,
|
||||
);
|
||||
|
||||
when(mockRepository.getMyProfile())
|
||||
.thenAnswer((_) async => testProfile);
|
||||
when(mockRepository.getMyProfileDetails())
|
||||
.thenAnswer((_) async => testProfileDetails);
|
||||
|
||||
ProfileAvatarState? loadedState;
|
||||
final subscription = cubit!.stream.listen((state) {
|
||||
if (state is ProfileAvatarStateLoaded) {
|
||||
loadedState = state;
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
await cubit!.getMyProfile();
|
||||
await Future.delayed(const Duration(milliseconds: 100));
|
||||
} catch (e) {
|
||||
// Игнорируем ошибки плагинов
|
||||
} finally {
|
||||
await subscription.cancel();
|
||||
}
|
||||
|
||||
// Профиль должен быть загружен, даже если дата в неправильном формате
|
||||
expect(loadedState, isNotNull);
|
||||
});
|
||||
|
||||
test('getMyProfile - обработка даты рождения с невалидными числами', () async {
|
||||
cubit = ProfileAvatarCubit(profileRepository: mockRepository);
|
||||
|
||||
const testProfile = ProfileModel(
|
||||
id: 1,
|
||||
slug: 'test-user',
|
||||
name: 'Test User',
|
||||
email: 'test@example.com',
|
||||
phoneConfirmationState: 'confirmed',
|
||||
isPublicProfile: true,
|
||||
isBlockedByMe: false,
|
||||
isBlockedByAuthor: false,
|
||||
isBlockedByUser: false,
|
||||
isEntitySubscribedByMe: false,
|
||||
profilePhotoUrl: 'https://example.com/icon.jpg',
|
||||
roles: [],
|
||||
badges: [],
|
||||
subscriptions: [],
|
||||
subscribers: [],
|
||||
);
|
||||
|
||||
// Дата с невалидными числами (не может быть распарсена)
|
||||
const testProfileDetails = ProfileDetailsModel(
|
||||
firstName: 'Test',
|
||||
lastName: 'User',
|
||||
birthdate: 'abc-def-ghi',
|
||||
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: 'No allergies',
|
||||
aboutTakingPils: 'No pills',
|
||||
aboutSportHobbies: 'Running',
|
||||
favoriteFoods: 'Fruits',
|
||||
unfavoriteFoods: 'Fast food',
|
||||
additionalInfo: 'Additional info',
|
||||
isMoodFillable: true,
|
||||
isBedtimeFillable: true,
|
||||
activityCoefficient: 1.5,
|
||||
);
|
||||
|
||||
when(mockRepository.getMyProfile())
|
||||
.thenAnswer((_) async => testProfile);
|
||||
when(mockRepository.getMyProfileDetails())
|
||||
.thenAnswer((_) async => testProfileDetails);
|
||||
|
||||
ProfileAvatarState? loadedState;
|
||||
final subscription = cubit!.stream.listen((state) {
|
||||
if (state is ProfileAvatarStateLoaded) {
|
||||
loadedState = state;
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
await cubit!.getMyProfile();
|
||||
await Future.delayed(const Duration(milliseconds: 100));
|
||||
} catch (e) {
|
||||
// Игнорируем ошибки плагинов
|
||||
} finally {
|
||||
await subscription.cancel();
|
||||
}
|
||||
|
||||
// Профиль должен быть загружен, даже если дата невалидна
|
||||
expect(loadedState, isNotNull);
|
||||
});
|
||||
|
||||
test('getMyProfile - обработка даты рождения с неполным форматом', () async {
|
||||
cubit = ProfileAvatarCubit(profileRepository: mockRepository);
|
||||
|
||||
const testProfile = ProfileModel(
|
||||
id: 1,
|
||||
slug: 'test-user',
|
||||
name: 'Test User',
|
||||
email: 'test@example.com',
|
||||
phoneConfirmationState: 'confirmed',
|
||||
isPublicProfile: true,
|
||||
isBlockedByMe: false,
|
||||
isBlockedByAuthor: false,
|
||||
isBlockedByUser: false,
|
||||
isEntitySubscribedByMe: false,
|
||||
profilePhotoUrl: 'https://example.com/icon.jpg',
|
||||
roles: [],
|
||||
badges: [],
|
||||
subscriptions: [],
|
||||
subscribers: [],
|
||||
);
|
||||
|
||||
// Дата с неполным форматом (только 2 части вместо 3)
|
||||
const testProfileDetails = ProfileDetailsModel(
|
||||
firstName: 'Test',
|
||||
lastName: 'User',
|
||||
birthdate: '1990-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: 'No allergies',
|
||||
aboutTakingPils: 'No pills',
|
||||
aboutSportHobbies: 'Running',
|
||||
favoriteFoods: 'Fruits',
|
||||
unfavoriteFoods: 'Fast food',
|
||||
additionalInfo: 'Additional info',
|
||||
isMoodFillable: true,
|
||||
isBedtimeFillable: true,
|
||||
activityCoefficient: 1.5,
|
||||
);
|
||||
|
||||
when(mockRepository.getMyProfile())
|
||||
.thenAnswer((_) async => testProfile);
|
||||
when(mockRepository.getMyProfileDetails())
|
||||
.thenAnswer((_) async => testProfileDetails);
|
||||
|
||||
ProfileAvatarState? loadedState;
|
||||
final subscription = cubit!.stream.listen((state) {
|
||||
if (state is ProfileAvatarStateLoaded) {
|
||||
loadedState = state;
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
await cubit!.getMyProfile();
|
||||
await Future.delayed(const Duration(milliseconds: 100));
|
||||
} catch (e) {
|
||||
// Игнорируем ошибки плагинов
|
||||
} finally {
|
||||
await subscription.cancel();
|
||||
}
|
||||
|
||||
// Профиль должен быть загружен, даже если дата в неполном формате
|
||||
expect(loadedState, isNotNull);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@ import 'package:eatvio_app/core/app_config.dart';
|
||||
import 'package:eatvio_app/core/app_initializer.dart';
|
||||
import 'package:eatvio_app/core/locator_service.dart';
|
||||
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
||||
import 'package:eatvio_app/features/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_navigation_bar_widget.dart';
|
||||
import 'package:eatvio_app/features/chat/presentation/bloc/chats_cubit/chats_cubit.dart';
|
||||
import 'package:eatvio_app/features/chat/presentation/bloc/chats_cubit/chats_state.dart';
|
||||
@@ -20,7 +22,7 @@ import 'package:network_image_mock/network_image_mock.dart';
|
||||
|
||||
import 'bottom_navigation_bar_widget_test.mocks.dart';
|
||||
|
||||
@GenerateNiceMocks([MockSpec<NotificationsCubit>(), MockSpec<ChatsCubit>()])
|
||||
@GenerateNiceMocks([MockSpec<NotificationsCubit>(), MockSpec<ChatsCubit>(), MockSpec<ProfileAvatarCubit>()])
|
||||
void main() {
|
||||
group('BottomNavigationBarWidget smoke', () {
|
||||
setUpAll(() async {
|
||||
@@ -86,6 +88,7 @@ void main() {
|
||||
mockNetworkImagesFor(() async {
|
||||
final notificationsCubit = MockNotificationsCubit();
|
||||
final chatsCubit = MockChatsCubit();
|
||||
final profileAvatarCubit = MockProfileAvatarCubit();
|
||||
|
||||
when(notificationsCubit.state).thenReturn(const NotificationsState());
|
||||
when(notificationsCubit.stream).thenAnswer((_) => const Stream.empty());
|
||||
@@ -93,16 +96,20 @@ void main() {
|
||||
when(chatsCubit.state).thenReturn(ChatsState());
|
||||
when(chatsCubit.stream).thenAnswer((_) => const Stream.empty());
|
||||
when(chatsCubit.close()).thenAnswer((_) async => Future<void>.value());
|
||||
when(profileAvatarCubit.state).thenReturn(const ProfileAvatarState.loaded(profile: mockUserProfile));
|
||||
when(profileAvatarCubit.stream).thenAnswer((_) => const Stream.empty());
|
||||
when(profileAvatarCubit.close()).thenAnswer((_) async => Future<void>.value());
|
||||
|
||||
await tester.pumpWidget(
|
||||
MultiBlocProvider(
|
||||
providers: [
|
||||
BlocProvider<NotificationsCubit>.value(value: notificationsCubit),
|
||||
BlocProvider<ChatsCubit>.value(value: chatsCubit),
|
||||
BlocProvider<ProfileAvatarCubit>.value(value: profileAvatarCubit),
|
||||
],
|
||||
child: wrap(
|
||||
const Scaffold(
|
||||
body: BottomNavigationBarWidget(profileModel: mockUserProfile, child: Text('Test Content')),
|
||||
body: BottomNavigationBarWidget(child: Text('Test Content')),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -117,6 +124,7 @@ void main() {
|
||||
mockNetworkImagesFor(() async {
|
||||
final notificationsCubit = MockNotificationsCubit();
|
||||
final chatsCubit = MockChatsCubit();
|
||||
final profileAvatarCubit = MockProfileAvatarCubit();
|
||||
|
||||
when(notificationsCubit.state).thenReturn(const NotificationsState());
|
||||
when(notificationsCubit.stream).thenAnswer((_) => const Stream.empty());
|
||||
@@ -124,16 +132,20 @@ void main() {
|
||||
when(chatsCubit.state).thenReturn(ChatsState());
|
||||
when(chatsCubit.stream).thenAnswer((_) => const Stream.empty());
|
||||
when(chatsCubit.close()).thenAnswer((_) async => Future<void>.value());
|
||||
when(profileAvatarCubit.state).thenReturn(const ProfileAvatarState.loaded(profile: mockConsultantProfile));
|
||||
when(profileAvatarCubit.stream).thenAnswer((_) => const Stream.empty());
|
||||
when(profileAvatarCubit.close()).thenAnswer((_) async => Future<void>.value());
|
||||
|
||||
await tester.pumpWidget(
|
||||
MultiBlocProvider(
|
||||
providers: [
|
||||
BlocProvider<NotificationsCubit>.value(value: notificationsCubit),
|
||||
BlocProvider<ChatsCubit>.value(value: chatsCubit),
|
||||
BlocProvider<ProfileAvatarCubit>.value(value: profileAvatarCubit),
|
||||
],
|
||||
child: wrap(
|
||||
const Scaffold(
|
||||
body: BottomNavigationBarWidget(profileModel: mockConsultantProfile, child: Text('Test Content')),
|
||||
body: BottomNavigationBarWidget(child: Text('Test Content')),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -43,6 +43,50 @@ void main() {
|
||||
expect(model.mediaData, isNotNull);
|
||||
expect(model.mediaData!.length, 1);
|
||||
});
|
||||
|
||||
test('toJson serializes ChatMessageModel correctly', () {
|
||||
final model = ChatMessageModel(
|
||||
id: 1,
|
||||
data: 'Test message',
|
||||
mediaData: null,
|
||||
authorId: 123,
|
||||
updatedAt: DateTime(2024, 1, 1, 12, 0, 0),
|
||||
isRead: false,
|
||||
isCurrentUserAuthor: true,
|
||||
roomId: 'room-1',
|
||||
type: 'text',
|
||||
);
|
||||
|
||||
final json = model.toJson();
|
||||
|
||||
expect(json['id'], 1);
|
||||
expect(json['data'], 'Test message');
|
||||
expect(json['author_id'], 123);
|
||||
expect(json['read_by_to_user'], false);
|
||||
expect(json['is_author_message_auth_user'], true);
|
||||
expect(json['room_id'], 'room-1');
|
||||
expect(json['type'], 'text');
|
||||
});
|
||||
|
||||
test('toJson handles null fields correctly', () {
|
||||
final model = ChatMessageModel(
|
||||
id: 1,
|
||||
data: 'Test message',
|
||||
mediaData: null,
|
||||
authorId: 123,
|
||||
updatedAt: DateTime(2024, 1, 1, 12, 0, 0),
|
||||
isRead: false,
|
||||
isCurrentUserAuthor: null,
|
||||
roomId: null,
|
||||
type: null,
|
||||
);
|
||||
|
||||
final json = model.toJson();
|
||||
|
||||
expect(json['is_author_message_auth_user'], isNull);
|
||||
expect(json['room_id'], isNull);
|
||||
expect(json['type'], isNull);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -37,5 +37,30 @@ void main() {
|
||||
expect(model.messagesList.first.data, 'First message');
|
||||
expect(model.meta.lastPage, 1);
|
||||
});
|
||||
|
||||
test('toJson serializes ChatMessagesModel correctly', () {
|
||||
final message = ChatMessageModel(
|
||||
id: 1,
|
||||
data: 'Test message',
|
||||
mediaData: null,
|
||||
authorId: 123,
|
||||
updatedAt: DateTime(2024, 1, 1, 12, 0, 0),
|
||||
isRead: false,
|
||||
isCurrentUserAuthor: true,
|
||||
roomId: 'room-1',
|
||||
type: 'text',
|
||||
);
|
||||
|
||||
const meta = ChatMetaModel(lastPage: 1);
|
||||
|
||||
final model = ChatMessagesModel(messagesList: [message], meta: meta);
|
||||
|
||||
final json = model.toJson();
|
||||
|
||||
expect(json, isNotNull);
|
||||
expect(json, isA<Map<String, dynamic>>());
|
||||
expect(json.containsKey('data'), isTrue);
|
||||
expect((json['data'] as List).length, 1);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -20,6 +20,14 @@ void main() {
|
||||
|
||||
expect(model.lastPage, 5);
|
||||
});
|
||||
|
||||
test('toJson serializes ChatMetaModel correctly', () {
|
||||
const model = ChatMetaModel(lastPage: 5);
|
||||
|
||||
final json = model.toJson();
|
||||
|
||||
expect(json['last_page'], 5);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -51,6 +51,46 @@ void main() {
|
||||
expect(model.users.sender.id, 1);
|
||||
expect(model.users.receiver.id, 2);
|
||||
});
|
||||
|
||||
test('toJson serializes ChatRoomModel correctly', () {
|
||||
const settings = ChatRoomSettingsModel(
|
||||
name: 'Room Name',
|
||||
state: 'active',
|
||||
);
|
||||
|
||||
const sender = ChatUserModel(
|
||||
id: 1,
|
||||
name: 'Sender',
|
||||
slug: 'sender',
|
||||
profilePhotoUrl: 'https://example.com/sender.jpg',
|
||||
);
|
||||
|
||||
const receiver = ChatUserModel(
|
||||
id: 2,
|
||||
name: 'Receiver',
|
||||
slug: 'receiver',
|
||||
profilePhotoUrl: 'https://example.com/receiver.jpg',
|
||||
);
|
||||
|
||||
const users = Users(sender: sender, receiver: receiver);
|
||||
|
||||
const model = ChatRoomModel(
|
||||
id: 'room-1',
|
||||
chatRoomSettings: settings,
|
||||
users: users,
|
||||
chosenPlanId: 1,
|
||||
chosenPlan: null,
|
||||
);
|
||||
|
||||
final json = model.toJson();
|
||||
|
||||
expect(json, isNotNull);
|
||||
expect(json, isA<Map<String, dynamic>>());
|
||||
expect(json['id'], 'room-1');
|
||||
expect(json['chosen_plan_id'], 1);
|
||||
expect(json.containsKey('chat_room_setting'), isTrue);
|
||||
expect(json.containsKey('users'), isTrue);
|
||||
});
|
||||
});
|
||||
|
||||
group('Users', () {
|
||||
@@ -63,6 +103,31 @@ void main() {
|
||||
expect(users.sender.id, 1);
|
||||
expect(users.receiver.id, 2);
|
||||
});
|
||||
|
||||
test('toJson serializes Users correctly', () {
|
||||
const sender = ChatUserModel(
|
||||
id: 1,
|
||||
name: 'Sender',
|
||||
slug: 'sender',
|
||||
profilePhotoUrl: 'https://example.com/sender.jpg',
|
||||
);
|
||||
|
||||
const receiver = ChatUserModel(
|
||||
id: 2,
|
||||
name: 'Receiver',
|
||||
slug: 'receiver',
|
||||
profilePhotoUrl: 'https://example.com/receiver.jpg',
|
||||
);
|
||||
|
||||
const users = Users(sender: sender, receiver: receiver);
|
||||
|
||||
final json = users.toJson();
|
||||
|
||||
expect(json, isNotNull);
|
||||
expect(json, isA<Map<String, dynamic>>());
|
||||
expect(json.containsKey('from_chat_user'), isTrue);
|
||||
expect(json.containsKey('to_chat_user'), isTrue);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -141,6 +141,67 @@ void main() {
|
||||
expect(chatUser.name, 'User 1');
|
||||
expect(chatUser.profilePhotoUrl, 'https://example.com/user1.jpg');
|
||||
});
|
||||
|
||||
test('toJson serializes ChatUser correctly', () {
|
||||
const chatUser = ChatUser(
|
||||
id: 1,
|
||||
name: 'User 1',
|
||||
profilePhotoPath: null,
|
||||
profilePhotoUrl: 'https://example.com/user1.jpg',
|
||||
);
|
||||
|
||||
final json = chatUser.toJson();
|
||||
|
||||
expect(json['id'], 1);
|
||||
expect(json['name'], 'User 1');
|
||||
expect(json['profile_photo_url'], 'https://example.com/user1.jpg');
|
||||
});
|
||||
});
|
||||
|
||||
group('ChatRoomPaginationModel', () {
|
||||
test('toJson serializes ChatRoomPaginationModel correctly', () {
|
||||
const chatRoom = ChatRoom(
|
||||
id: 'room-1',
|
||||
lastMessage: null,
|
||||
users: Users(
|
||||
fromChatUser: ChatUser(
|
||||
id: 1,
|
||||
name: 'User 1',
|
||||
profilePhotoPath: null,
|
||||
profilePhotoUrl: 'https://example.com/user1.jpg',
|
||||
),
|
||||
toChatUser: ChatUser(
|
||||
id: 2,
|
||||
name: 'User 2',
|
||||
profilePhotoPath: null,
|
||||
profilePhotoUrl: 'https://example.com/user2.jpg',
|
||||
),
|
||||
),
|
||||
unreadMessagesCount: 5,
|
||||
chosenPlan: null,
|
||||
);
|
||||
|
||||
const pagination = ChatRoomPaginationModel(
|
||||
data: [chatRoom],
|
||||
unreadChatsCount: 1,
|
||||
meta: Meta(
|
||||
currentPage: 1,
|
||||
from: 1,
|
||||
lastPage: 1,
|
||||
links: [],
|
||||
perPage: 10,
|
||||
to: 1,
|
||||
total: 1,
|
||||
),
|
||||
);
|
||||
|
||||
final json = pagination.toJson();
|
||||
|
||||
expect(json.containsKey('data'), isTrue);
|
||||
expect(json.containsKey('number_of_unread_chat_rooms'), isTrue);
|
||||
expect(json.containsKey('meta'), isTrue);
|
||||
expect(json['number_of_unread_chat_rooms'], 1);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -24,6 +24,18 @@ void main() {
|
||||
expect(model.name, 'Room Name');
|
||||
expect(model.state, 'active');
|
||||
});
|
||||
|
||||
test('toJson serializes ChatRoomSettingsModel correctly', () {
|
||||
const model = ChatRoomSettingsModel(
|
||||
name: 'Room Name',
|
||||
state: 'active',
|
||||
);
|
||||
|
||||
final json = model.toJson();
|
||||
|
||||
expect(json['room_name'], 'Room Name');
|
||||
expect(json['state'], 'active');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -57,6 +57,35 @@ void main() {
|
||||
expect(model.name, 'User Name');
|
||||
expect(model.profilePhotoUrl, isNull);
|
||||
});
|
||||
|
||||
test('toJson serializes ChatUserModel correctly', () {
|
||||
const model = ChatUserModel(
|
||||
id: 1,
|
||||
name: 'User Name',
|
||||
slug: 'user-slug',
|
||||
profilePhotoUrl: 'https://example.com/photo.jpg',
|
||||
);
|
||||
|
||||
final json = model.toJson();
|
||||
|
||||
expect(json['id'], 1);
|
||||
expect(json['name'], 'User Name');
|
||||
expect(json['slug'], 'user-slug');
|
||||
expect(json['profile_photo_url'], 'https://example.com/photo.jpg');
|
||||
});
|
||||
|
||||
test('toJson handles null profilePhotoUrl', () {
|
||||
const model = ChatUserModel(
|
||||
id: 1,
|
||||
name: 'User Name',
|
||||
slug: 'user-slug',
|
||||
profilePhotoUrl: null,
|
||||
);
|
||||
|
||||
final json = model.toJson();
|
||||
|
||||
expect(json['profile_photo_url'], isNull);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -3,23 +3,26 @@ import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
group('PlanComplainModel', () {
|
||||
test('создает PlanComplainModel с правильными значениями', () {
|
||||
final model = PlanComplainModel(
|
||||
description: 'Test complaint',
|
||||
);
|
||||
|
||||
expect(model.description, 'Test complaint');
|
||||
});
|
||||
|
||||
test('fromJson создает PlanComplainModel из JSON', () {
|
||||
final json = {
|
||||
'description': 'Test complaint',
|
||||
};
|
||||
test('fromJson parses description correctly', () {
|
||||
final json = {'description': 'Test complaint description'};
|
||||
|
||||
final model = PlanComplainModel.fromJson(json);
|
||||
|
||||
expect(model.description, 'Test complaint');
|
||||
expect(model.description, 'Test complaint description');
|
||||
});
|
||||
|
||||
test('toJson serializes description correctly', () {
|
||||
final model = PlanComplainModel(description: 'Test complaint description');
|
||||
|
||||
final json = model.toJson();
|
||||
|
||||
expect(json['description'], 'Test complaint description');
|
||||
});
|
||||
|
||||
test('creates instance with description', () {
|
||||
final model = PlanComplainModel(description: 'Another complaint');
|
||||
|
||||
expect(model.description, 'Another complaint');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,6 +2,7 @@ import 'dart:async';
|
||||
import 'package:eatvio_app/core/enums/base_state.dart';
|
||||
import 'package:eatvio_app/core/models/pagination_model.dart';
|
||||
import 'package:eatvio_app/core/websockets/ws_manager.dart';
|
||||
import 'package:eatvio_app/features/chat/models/chat_message_model.dart';
|
||||
import 'package:eatvio_app/features/chat/models/chat_room_pagination_model.dart';
|
||||
import 'package:eatvio_app/features/chat/presentation/bloc/chats_cubit/chats_cubit.dart';
|
||||
import 'package:eatvio_app/features/chat/presentation/bloc/chats_cubit/chats_state.dart';
|
||||
@@ -194,5 +195,284 @@ void main() {
|
||||
// newLastMessageSubscription is cancelled in super.close() which we can't easily verify
|
||||
// but the test verifies that the method completes without error
|
||||
});
|
||||
|
||||
test('reduceUnreadMessagesCount - уменьшает до 0 и уменьшает chatsWithUnreadMessagesCount', () {
|
||||
const chatId = 'chat-id';
|
||||
const count = 5;
|
||||
|
||||
const chatRoom = ChatRoom(
|
||||
id: chatId,
|
||||
lastMessage: null,
|
||||
users: Users(
|
||||
fromChatUser: ChatUser(id: 1, name: 'User 1', profilePhotoPath: null, profilePhotoUrl: ''),
|
||||
toChatUser: ChatUser(id: 2, name: 'User 2', profilePhotoPath: null, profilePhotoUrl: ''),
|
||||
),
|
||||
unreadMessagesCount: 5,
|
||||
chosenPlan: null,
|
||||
);
|
||||
|
||||
const pagination = ChatRoomPaginationModel(
|
||||
data: [chatRoom],
|
||||
unreadChatsCount: 1,
|
||||
meta: Meta(currentPage: 1, from: null, lastPage: 1, links: [], perPage: 10, to: null, total: 1),
|
||||
);
|
||||
|
||||
chatsCubit.emit(ChatsState(state: BaseState.success, chatRooms: pagination.data, chatsWithUnreadMessagesCount: 1));
|
||||
|
||||
chatsCubit.reduceUnreadMessagesCount(chatId: chatId, count: count);
|
||||
|
||||
final updatedState = chatsCubit.state;
|
||||
final updatedRoom = updatedState.chatRooms.firstWhere((r) => r.id == chatId);
|
||||
expect(updatedRoom.unreadMessagesCount, 0);
|
||||
expect(updatedState.chatsWithUnreadMessagesCount, 0);
|
||||
});
|
||||
|
||||
test('reduceUnreadMessagesCount - не уменьшает chatsWithUnreadMessagesCount если unreadMessagesCount > 0', () {
|
||||
const chatId = 'chat-id';
|
||||
const count = 2;
|
||||
|
||||
const chatRoom = ChatRoom(
|
||||
id: chatId,
|
||||
lastMessage: null,
|
||||
users: Users(
|
||||
fromChatUser: ChatUser(id: 1, name: 'User 1', profilePhotoPath: null, profilePhotoUrl: ''),
|
||||
toChatUser: ChatUser(id: 2, name: 'User 2', profilePhotoPath: null, profilePhotoUrl: ''),
|
||||
),
|
||||
unreadMessagesCount: 5,
|
||||
chosenPlan: null,
|
||||
);
|
||||
|
||||
const pagination = ChatRoomPaginationModel(
|
||||
data: [chatRoom],
|
||||
unreadChatsCount: 1,
|
||||
meta: Meta(currentPage: 1, from: null, lastPage: 1, links: [], perPage: 10, to: null, total: 1),
|
||||
);
|
||||
|
||||
chatsCubit.emit(ChatsState(state: BaseState.success, chatRooms: pagination.data, chatsWithUnreadMessagesCount: 1));
|
||||
|
||||
chatsCubit.reduceUnreadMessagesCount(chatId: chatId, count: count);
|
||||
|
||||
final updatedState = chatsCubit.state;
|
||||
expect(updatedState.chatsWithUnreadMessagesCount, 1);
|
||||
});
|
||||
|
||||
test('loadMoreChatRooms - не загружает если lastPage == page', () async {
|
||||
const paginationPage1 = ChatRoomPaginationModel(
|
||||
data: [],
|
||||
unreadChatsCount: 0,
|
||||
meta: Meta(currentPage: 1, from: null, lastPage: 1, links: [], perPage: 10, to: null, total: 10),
|
||||
);
|
||||
|
||||
when(mockChatRepository.getChatRooms(query: null, page: 1)).thenAnswer((_) async => paginationPage1);
|
||||
|
||||
await chatsCubit.getChatRooms();
|
||||
await Future.delayed(const Duration(milliseconds: 100));
|
||||
|
||||
// lastPage = meta.lastPage + 1 = 2, page = 2, поэтому не должно загружать
|
||||
await chatsCubit.loadMoreChatRooms();
|
||||
|
||||
verifyNever(mockChatRepository.getChatRooms(query: null, page: 2));
|
||||
});
|
||||
|
||||
test('loadMoreChatRooms - обрабатывает ошибку', () async {
|
||||
const paginationPage1 = ChatRoomPaginationModel(
|
||||
data: [],
|
||||
unreadChatsCount: 0,
|
||||
meta: Meta(currentPage: 1, from: null, lastPage: 3, links: [], perPage: 10, to: null, total: 30),
|
||||
);
|
||||
|
||||
when(mockChatRepository.getChatRooms(query: null, page: 1)).thenAnswer((_) async => paginationPage1);
|
||||
when(mockChatRepository.getChatRooms(query: null, page: 2)).thenThrow(Exception('Ошибка загрузки'));
|
||||
|
||||
await chatsCubit.getChatRooms();
|
||||
await Future.delayed(const Duration(milliseconds: 100));
|
||||
|
||||
await chatsCubit.loadMoreChatRooms();
|
||||
await Future.delayed(const Duration(milliseconds: 100));
|
||||
|
||||
expect(chatsCubit.state.state, BaseState.error);
|
||||
expect(chatsCubit.state.errorMessage, isNotNull);
|
||||
expect(chatsCubit.state.isLoadingMoreChats, false);
|
||||
});
|
||||
|
||||
test('getChatRooms - обрабатывает ошибку', () async {
|
||||
when(mockChatRepository.getChatRooms(query: null)).thenThrow(Exception('Network error'));
|
||||
|
||||
await chatsCubit.getChatRooms();
|
||||
|
||||
await Future.delayed(const Duration(milliseconds: 100));
|
||||
expect(chatsCubit.state.state, BaseState.error);
|
||||
expect(chatsCubit.state.errorMessage, isNotNull);
|
||||
});
|
||||
|
||||
test('_setNewLastMessage - обновляет последнее сообщение в чате', () async {
|
||||
const chatId = 'chat-id';
|
||||
const chatRoom = ChatRoom(
|
||||
id: chatId,
|
||||
lastMessage: null,
|
||||
users: Users(
|
||||
fromChatUser: ChatUser(id: 1, name: 'User 1', profilePhotoPath: null, profilePhotoUrl: ''),
|
||||
toChatUser: ChatUser(id: 2, name: 'User 2', profilePhotoPath: null, profilePhotoUrl: ''),
|
||||
),
|
||||
unreadMessagesCount: 0,
|
||||
chosenPlan: null,
|
||||
);
|
||||
|
||||
const pagination = ChatRoomPaginationModel(
|
||||
data: [chatRoom],
|
||||
unreadChatsCount: 0,
|
||||
meta: Meta(currentPage: 1, from: null, lastPage: 1, links: [], perPage: 10, to: null, total: 1),
|
||||
);
|
||||
|
||||
chatsCubit.emit(ChatsState(state: BaseState.success, chatRooms: pagination.data));
|
||||
|
||||
final newMessage = ChatMessageModel(
|
||||
id: 1,
|
||||
data: 'Test message',
|
||||
mediaData: null,
|
||||
urlsMediaInfo: null,
|
||||
authorId: 1,
|
||||
updatedAt: DateTime.now(),
|
||||
isRead: false,
|
||||
isCurrentUserAuthor: true,
|
||||
roomId: chatId,
|
||||
type: 'text',
|
||||
);
|
||||
|
||||
// Используем рефлексию для вызова приватного метода через тестовый доступ
|
||||
// Или проверяем через публичный API, если есть
|
||||
// В данном случае, _setNewLastMessage вызывается через newUserMessagesStream
|
||||
final streamController = StreamController<ChatMessageModel>();
|
||||
when(mockChatRepository.newUserMessagesStream).thenAnswer((_) => streamController.stream);
|
||||
|
||||
// Пересоздаем cubit чтобы подписаться на stream
|
||||
await chatsCubit.close();
|
||||
chatsCubit = ChatsCubit(chatRepository: mockChatRepository, wsManager: mockWsManager, profileRepository: mockProfileRepository);
|
||||
chatsCubit.emit(ChatsState(state: BaseState.success, chatRooms: pagination.data));
|
||||
|
||||
streamController.add(newMessage);
|
||||
await Future.delayed(const Duration(milliseconds: 100));
|
||||
|
||||
final updatedState = chatsCubit.state;
|
||||
final updatedRoom = updatedState.chatRooms.firstWhere((r) => r.id == chatId);
|
||||
expect(updatedRoom.lastMessage, isNotNull);
|
||||
expect(updatedRoom.lastMessage?.id, 1);
|
||||
|
||||
await streamController.close();
|
||||
});
|
||||
|
||||
test('_setNewLastMessage - обновляет последнее сообщение через newUserMessagesStream', () async {
|
||||
const chatId = 'chat-id';
|
||||
const chatRoom = ChatRoom(
|
||||
id: chatId,
|
||||
lastMessage: null,
|
||||
users: Users(
|
||||
fromChatUser: ChatUser(id: 1, name: 'User 1', profilePhotoPath: null, profilePhotoUrl: ''),
|
||||
toChatUser: ChatUser(id: 2, name: 'User 2', profilePhotoPath: null, profilePhotoUrl: ''),
|
||||
),
|
||||
unreadMessagesCount: 0,
|
||||
chosenPlan: null,
|
||||
);
|
||||
|
||||
const pagination = ChatRoomPaginationModel(
|
||||
data: [chatRoom],
|
||||
unreadChatsCount: 0,
|
||||
meta: Meta(currentPage: 1, from: null, lastPage: 1, links: [], perPage: 10, to: null, total: 1),
|
||||
);
|
||||
|
||||
final newMessage = ChatMessageModel(
|
||||
id: 1,
|
||||
data: 'Test message',
|
||||
mediaData: null,
|
||||
urlsMediaInfo: null,
|
||||
authorId: 1,
|
||||
updatedAt: DateTime.now(),
|
||||
isRead: false,
|
||||
isCurrentUserAuthor: true,
|
||||
roomId: chatId,
|
||||
type: 'text',
|
||||
);
|
||||
|
||||
final streamController = StreamController<ChatMessageModel>();
|
||||
when(mockChatRepository.newUserMessagesStream).thenAnswer((_) => streamController.stream);
|
||||
|
||||
// Пересоздаем cubit чтобы подписаться на stream
|
||||
await chatsCubit.close();
|
||||
chatsCubit = ChatsCubit(chatRepository: mockChatRepository, wsManager: mockWsManager, profileRepository: mockProfileRepository);
|
||||
chatsCubit.emit(ChatsState(state: BaseState.success, chatRooms: pagination.data));
|
||||
|
||||
streamController.add(newMessage);
|
||||
await Future.delayed(const Duration(milliseconds: 100));
|
||||
|
||||
final updatedState = chatsCubit.state;
|
||||
final updatedRoom = updatedState.chatRooms.firstWhere((r) => r.id == chatId);
|
||||
expect(updatedRoom.lastMessage, isNotNull);
|
||||
expect(updatedRoom.lastMessage?.id, 1);
|
||||
|
||||
await streamController.close();
|
||||
});
|
||||
|
||||
test('reduceUnreadMessagesCount - обрабатывает чат который не существует', () {
|
||||
const chatId = 'non-existent-chat';
|
||||
const count = 2;
|
||||
|
||||
const chatRoom = ChatRoom(
|
||||
id: 'other-chat-id',
|
||||
lastMessage: null,
|
||||
users: Users(
|
||||
fromChatUser: ChatUser(id: 1, name: 'User 1', profilePhotoPath: null, profilePhotoUrl: ''),
|
||||
toChatUser: ChatUser(id: 2, name: 'User 2', profilePhotoPath: null, profilePhotoUrl: ''),
|
||||
),
|
||||
unreadMessagesCount: 5,
|
||||
chosenPlan: null,
|
||||
);
|
||||
|
||||
const pagination = ChatRoomPaginationModel(
|
||||
data: [chatRoom],
|
||||
unreadChatsCount: 1,
|
||||
meta: Meta(currentPage: 1, from: null, lastPage: 1, links: [], perPage: 10, to: null, total: 1),
|
||||
);
|
||||
|
||||
chatsCubit.emit(ChatsState(state: BaseState.success, chatRooms: pagination.data, chatsWithUnreadMessagesCount: 1));
|
||||
|
||||
chatsCubit.reduceUnreadMessagesCount(chatId: chatId, count: count);
|
||||
|
||||
final updatedState = chatsCubit.state;
|
||||
// Чат не должен измениться
|
||||
expect(updatedState.chatRooms.length, 1);
|
||||
expect(updatedState.chatRooms.first.unreadMessagesCount, 5);
|
||||
expect(updatedState.chatsWithUnreadMessagesCount, 1);
|
||||
});
|
||||
|
||||
test('reduceUnreadMessagesCount - обрабатывает уменьшение больше чем unreadMessagesCount', () {
|
||||
const chatId = 'chat-id';
|
||||
const count = 10; // Больше чем unreadMessagesCount
|
||||
|
||||
const chatRoom = ChatRoom(
|
||||
id: chatId,
|
||||
lastMessage: null,
|
||||
users: Users(
|
||||
fromChatUser: ChatUser(id: 1, name: 'User 1', profilePhotoPath: null, profilePhotoUrl: ''),
|
||||
toChatUser: ChatUser(id: 2, name: 'User 2', profilePhotoPath: null, profilePhotoUrl: ''),
|
||||
),
|
||||
unreadMessagesCount: 5,
|
||||
chosenPlan: null,
|
||||
);
|
||||
|
||||
const pagination = ChatRoomPaginationModel(
|
||||
data: [chatRoom],
|
||||
unreadChatsCount: 1,
|
||||
meta: Meta(currentPage: 1, from: null, lastPage: 1, links: [], perPage: 10, to: null, total: 1),
|
||||
);
|
||||
|
||||
chatsCubit.emit(ChatsState(state: BaseState.success, chatRooms: pagination.data, chatsWithUnreadMessagesCount: 1));
|
||||
|
||||
chatsCubit.reduceUnreadMessagesCount(chatId: chatId, count: count);
|
||||
|
||||
final updatedState = chatsCubit.state;
|
||||
final updatedRoom = updatedState.chatRooms.firstWhere((r) => r.id == chatId);
|
||||
expect(updatedRoom.unreadMessagesCount, 0);
|
||||
expect(updatedState.chatsWithUnreadMessagesCount, 0);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -120,5 +120,131 @@ void main() {
|
||||
|
||||
expect(cubit.state.wasSentSuccessfully, false);
|
||||
});
|
||||
|
||||
test('complain - отправляет жалобу на plan', () async {
|
||||
const slug = 'test-plan-slug';
|
||||
|
||||
when(() => mockRepository.sendPlanComplain(
|
||||
slug: slug,
|
||||
category: any(named: 'category'),
|
||||
body: any(named: 'body'),
|
||||
)).thenAnswer((_) async => {});
|
||||
|
||||
cubit.setContentTypeAndIdentifier(contentType: ComplainedContent.plan, slug: slug);
|
||||
cubit.setCategory(ComplainType.spam);
|
||||
|
||||
await cubit.complain();
|
||||
|
||||
expect(cubit.state.wasSentSuccessfully, true);
|
||||
verify(() => mockRepository.sendPlanComplain(
|
||||
slug: slug,
|
||||
category: any(named: 'category'),
|
||||
body: any(named: 'body'),
|
||||
)).called(1);
|
||||
});
|
||||
|
||||
test('complain - отправляет жалобу на recipe', () async {
|
||||
const slug = 'test-recipe-slug';
|
||||
|
||||
when(() => mockRepository.sendRecipeComplain(
|
||||
slug: slug,
|
||||
category: any(named: 'category'),
|
||||
body: any(named: 'body'),
|
||||
)).thenAnswer((_) async => {});
|
||||
|
||||
cubit.setContentTypeAndIdentifier(contentType: ComplainedContent.recipe, slug: slug);
|
||||
cubit.setCategory(ComplainType.spam);
|
||||
|
||||
await cubit.complain();
|
||||
|
||||
expect(cubit.state.wasSentSuccessfully, true);
|
||||
verify(() => mockRepository.sendRecipeComplain(
|
||||
slug: slug,
|
||||
category: any(named: 'category'),
|
||||
body: any(named: 'body'),
|
||||
)).called(1);
|
||||
});
|
||||
|
||||
test('complain - отправляет жалобу на profile', () async {
|
||||
const slug = 'test-profile-slug';
|
||||
|
||||
when(() => mockRepository.sendProfileComplain(
|
||||
slug: slug,
|
||||
category: any(named: 'category'),
|
||||
body: any(named: 'body'),
|
||||
)).thenAnswer((_) async => {});
|
||||
|
||||
cubit.setContentTypeAndIdentifier(contentType: ComplainedContent.profile, slug: slug);
|
||||
cubit.setCategory(ComplainType.spam);
|
||||
|
||||
await cubit.complain();
|
||||
|
||||
expect(cubit.state.wasSentSuccessfully, true);
|
||||
verify(() => mockRepository.sendProfileComplain(
|
||||
slug: slug,
|
||||
category: any(named: 'category'),
|
||||
body: any(named: 'body'),
|
||||
)).called(1);
|
||||
});
|
||||
|
||||
test('complain - отправляет жалобу на posts', () async {
|
||||
const id = 1;
|
||||
|
||||
when(() => mockRepository.sendBlogPostsComplain(
|
||||
id: id,
|
||||
category: any(named: 'category'),
|
||||
body: any(named: 'body'),
|
||||
)).thenAnswer((_) async => {});
|
||||
|
||||
cubit.setContentTypeAndIdentifier(contentType: ComplainedContent.posts, id: id);
|
||||
cubit.setCategory(ComplainType.spam);
|
||||
|
||||
await cubit.complain();
|
||||
|
||||
expect(cubit.state.wasSentSuccessfully, true);
|
||||
verify(() => mockRepository.sendBlogPostsComplain(
|
||||
id: id,
|
||||
category: any(named: 'category'),
|
||||
body: any(named: 'body'),
|
||||
)).called(1);
|
||||
});
|
||||
|
||||
test('complain - отправляет жалобу на comment', () async {
|
||||
const id = 1;
|
||||
|
||||
when(() => mockRepository.sendCommentComplain(
|
||||
id: id,
|
||||
category: any(named: 'category'),
|
||||
body: any(named: 'body'),
|
||||
)).thenAnswer((_) async => {});
|
||||
|
||||
cubit.setContentTypeAndIdentifier(contentType: ComplainedContent.comment, id: id);
|
||||
cubit.setCategory(ComplainType.spam);
|
||||
|
||||
await cubit.complain();
|
||||
|
||||
expect(cubit.state.wasSentSuccessfully, true);
|
||||
verify(() => mockRepository.sendCommentComplain(
|
||||
id: id,
|
||||
category: any(named: 'category'),
|
||||
body: any(named: 'body'),
|
||||
)).called(1);
|
||||
});
|
||||
|
||||
test('complain - возвращает false если category null', () async {
|
||||
cubit.setContentTypeAndIdentifier(contentType: ComplainedContent.diet, id: 1);
|
||||
|
||||
await cubit.complain();
|
||||
|
||||
expect(cubit.state.wasSentSuccessfully, false);
|
||||
});
|
||||
|
||||
test('complain - возвращает false если contentType null', () async {
|
||||
cubit.setCategory(ComplainType.spam);
|
||||
|
||||
await cubit.complain();
|
||||
|
||||
expect(cubit.state.wasSentSuccessfully, false);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import 'package:eatvio_app/features/consultant/become_consultant/models/consultant_cert_photo_model.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
group('ConsultantCertPhotoModel', () {
|
||||
test('fromJson parses all fields correctly', () {
|
||||
final json = {
|
||||
'id': 1,
|
||||
'user_id': 100,
|
||||
'group': 'user_certs',
|
||||
'original_name': 'certificate.jpg',
|
||||
'path': '/path/to/certificate.jpg',
|
||||
'size': 1024,
|
||||
'specs': {'width': 800, 'height': 600},
|
||||
'url': 'https://example.com/certificate.jpg',
|
||||
};
|
||||
|
||||
final model = ConsultantCertPhotoModel.fromJson(json);
|
||||
|
||||
expect(model.id, 1);
|
||||
expect(model.userId, 100);
|
||||
expect(model.group, 'user_certs');
|
||||
expect(model.originalName, 'certificate.jpg');
|
||||
expect(model.path, '/path/to/certificate.jpg');
|
||||
expect(model.size, 1024);
|
||||
expect(model.specs, {'width': 800, 'height': 600});
|
||||
expect(model.url, 'https://example.com/certificate.jpg');
|
||||
});
|
||||
|
||||
test('fromJson handles optional fields', () {
|
||||
final json = {
|
||||
'id': 1,
|
||||
'group': 'user_certs',
|
||||
'original_name': 'certificate.jpg',
|
||||
'path': '/path/to/certificate.jpg',
|
||||
'size': 1024,
|
||||
'url': 'https://example.com/certificate.jpg',
|
||||
};
|
||||
|
||||
final model = ConsultantCertPhotoModel.fromJson(json);
|
||||
|
||||
expect(model.id, 1);
|
||||
expect(model.userId, isNull);
|
||||
expect(model.specs, isNull);
|
||||
});
|
||||
|
||||
test('toJson serializes all fields correctly', () {
|
||||
const model = ConsultantCertPhotoModel(
|
||||
id: 1,
|
||||
userId: 100,
|
||||
group: 'user_certs',
|
||||
originalName: 'certificate.jpg',
|
||||
path: '/path/to/certificate.jpg',
|
||||
size: 1024,
|
||||
specs: {'width': 800, 'height': 600},
|
||||
url: 'https://example.com/certificate.jpg',
|
||||
);
|
||||
|
||||
final json = model.toJson();
|
||||
|
||||
expect(json['id'], 1);
|
||||
expect(json['user_id'], 100);
|
||||
expect(json['group'], 'user_certs');
|
||||
expect(json['original_name'], 'certificate.jpg');
|
||||
expect(json['path'], '/path/to/certificate.jpg');
|
||||
expect(json['size'], 1024);
|
||||
expect(json['specs'], {'width': 800, 'height': 600});
|
||||
expect(json['url'], 'https://example.com/certificate.jpg');
|
||||
});
|
||||
|
||||
test('isHidden returns true for hidden group', () {
|
||||
const hiddenModel = ConsultantCertPhotoModel(
|
||||
id: 1,
|
||||
group: ConsultantCertPhotoModel.hiddenGroup,
|
||||
originalName: 'cert.jpg',
|
||||
path: '/path',
|
||||
size: 1024,
|
||||
url: 'https://example.com/cert.jpg',
|
||||
);
|
||||
|
||||
expect(hiddenModel.isHidden, isTrue);
|
||||
});
|
||||
|
||||
test('isHidden returns false for visible group', () {
|
||||
const visibleModel = ConsultantCertPhotoModel(
|
||||
id: 1,
|
||||
group: ConsultantCertPhotoModel.visibleGroup,
|
||||
originalName: 'cert.jpg',
|
||||
path: '/path',
|
||||
size: 1024,
|
||||
url: 'https://example.com/cert.jpg',
|
||||
);
|
||||
|
||||
expect(visibleModel.isHidden, isFalse);
|
||||
});
|
||||
|
||||
test('constants are defined correctly', () {
|
||||
expect(ConsultantCertPhotoModel.visibleGroup, 'user_certs');
|
||||
expect(ConsultantCertPhotoModel.hiddenGroup, 'hidden_user_certs');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
import 'package:eatvio_app/features/consultant/become_consultant/models/consultant_profile_model.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
group('SocialLinksModel', () {
|
||||
test('fromJson parses all fields correctly', () {
|
||||
final json = {
|
||||
'vk_link': 'https://vk.com/user',
|
||||
'tg_link': 'https://t.me/user',
|
||||
'fb_link': 'https://facebook.com/user',
|
||||
'insta_link': 'https://instagram.com/user',
|
||||
'personal_site_link': 'https://example.com',
|
||||
};
|
||||
|
||||
final model = SocialLinksModel.fromJson(json);
|
||||
|
||||
expect(model.vkLink, 'https://vk.com/user');
|
||||
expect(model.tgLink, 'https://t.me/user');
|
||||
expect(model.fbLink, 'https://facebook.com/user');
|
||||
expect(model.instaLink, 'https://instagram.com/user');
|
||||
expect(model.personalSiteLink, 'https://example.com');
|
||||
});
|
||||
|
||||
test('fromJson handles null fields', () {
|
||||
final json = <String, dynamic>{};
|
||||
|
||||
final model = SocialLinksModel.fromJson(json);
|
||||
|
||||
expect(model.vkLink, isNull);
|
||||
expect(model.tgLink, isNull);
|
||||
expect(model.fbLink, isNull);
|
||||
expect(model.instaLink, isNull);
|
||||
expect(model.personalSiteLink, isNull);
|
||||
});
|
||||
|
||||
test('toJson serializes all fields correctly', () {
|
||||
const model = SocialLinksModel(
|
||||
vkLink: 'https://vk.com/user',
|
||||
tgLink: 'https://t.me/user',
|
||||
fbLink: 'https://facebook.com/user',
|
||||
instaLink: 'https://instagram.com/user',
|
||||
personalSiteLink: 'https://example.com',
|
||||
);
|
||||
|
||||
final json = model.toJson();
|
||||
|
||||
expect(json['vk_link'], 'https://vk.com/user');
|
||||
expect(json['tg_link'], 'https://t.me/user');
|
||||
expect(json['fb_link'], 'https://facebook.com/user');
|
||||
expect(json['insta_link'], 'https://instagram.com/user');
|
||||
expect(json['personal_site_link'], 'https://example.com');
|
||||
});
|
||||
});
|
||||
|
||||
group('CertificatesModel', () {
|
||||
test('fromJson parses all fields correctly', () {
|
||||
final json = {'title': 'Certificate Title', 'date': '2024-01-15'};
|
||||
|
||||
final model = CertificatesModel.fromJson(json);
|
||||
|
||||
expect(model.title, 'Certificate Title');
|
||||
expect(model.date, '2024-01-15');
|
||||
});
|
||||
|
||||
test('toJson serializes all fields correctly', () {
|
||||
const model = CertificatesModel(title: 'Certificate Title', date: '2024-01-15');
|
||||
|
||||
final json = model.toJson();
|
||||
|
||||
expect(json['title'], 'Certificate Title');
|
||||
expect(json['date'], '2024-01-15');
|
||||
});
|
||||
});
|
||||
|
||||
group('ConsultantProfileModel', () {
|
||||
test('fromJson parses all fields correctly', () {
|
||||
final json = {
|
||||
'has_medical_education': true,
|
||||
'social_links': {'vk_link': 'https://vk.com/user'},
|
||||
'cert_photos': ['photo1.jpg', 'photo2.jpg'],
|
||||
'completed_courses': [
|
||||
{'title': 'Course 1', 'date': '2024-01-01'},
|
||||
],
|
||||
};
|
||||
|
||||
final model = ConsultantProfileModel.fromJson(json);
|
||||
|
||||
expect(model.hasMedicalEducation, isTrue);
|
||||
expect(model.socialLinks, isNotNull);
|
||||
expect(model.photos, ['photo1.jpg', 'photo2.jpg']);
|
||||
expect(model.certificates, isNotNull);
|
||||
expect(model.certificates?.length, 1);
|
||||
});
|
||||
|
||||
test('fromJson handles null fields', () {
|
||||
final json = <String, dynamic>{};
|
||||
|
||||
final model = ConsultantProfileModel.fromJson(json);
|
||||
|
||||
expect(model.hasMedicalEducation, isNull);
|
||||
expect(model.socialLinks, isNull);
|
||||
expect(model.photos, isNull);
|
||||
expect(model.certificates, isNull);
|
||||
});
|
||||
|
||||
test('toJson serializes all fields correctly', () {
|
||||
const socialLinks = SocialLinksModel(vkLink: 'https://vk.com/user');
|
||||
const certificates = CertificatesModel(title: 'Course 1', date: '2024-01-01');
|
||||
const model = ConsultantProfileModel(hasMedicalEducation: true, socialLinks: socialLinks, photos: ['photo1.jpg'], certificates: [certificates]);
|
||||
|
||||
final json = model.toJson();
|
||||
|
||||
expect(json['has_medical_education'], isTrue);
|
||||
expect(json['social_links'], isNotNull);
|
||||
expect(json['cert_photos'], ['photo1.jpg']);
|
||||
expect(json['completed_courses'], isNotNull);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import 'package:eatvio_app/features/consultant/become_consultant/models/consultation_request_model.dart';
|
||||
import 'package:eatvio_app/features/profile/models/profile_model.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
group('CertPhotosModel', () {
|
||||
test('fromJson parses url correctly', () {
|
||||
final json = {'url': 'https://example.com/photo.jpg'};
|
||||
|
||||
final model = CertPhotosModel.fromJson(json);
|
||||
|
||||
expect(model.url, 'https://example.com/photo.jpg');
|
||||
});
|
||||
|
||||
test('toJson serializes url correctly', () {
|
||||
const model = CertPhotosModel(url: 'https://example.com/photo.jpg');
|
||||
|
||||
final json = model.toJson();
|
||||
|
||||
expect(json['url'], 'https://example.com/photo.jpg');
|
||||
});
|
||||
});
|
||||
|
||||
group('ModerationStatus enum', () {
|
||||
test('has all expected values', () {
|
||||
expect(ModerationStatus.values.length, 3);
|
||||
expect(ModerationStatus.approved, isNotNull);
|
||||
expect(ModerationStatus.created, isNotNull);
|
||||
expect(ModerationStatus.canceled, isNotNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('CanceledModerationModel', () {
|
||||
test('fromJson parses all fields correctly', () {
|
||||
final json = {
|
||||
'title': 'Test title',
|
||||
'status': 'canceled',
|
||||
'additional': ['reason1', 'reason2'],
|
||||
};
|
||||
|
||||
final model = CanceledModerationModel.fromJson(json);
|
||||
|
||||
expect(model.title, 'Test title');
|
||||
expect(model.status, ModerationStatus.canceled);
|
||||
expect(model.additional, ['reason1', 'reason2']);
|
||||
});
|
||||
|
||||
test('fromJson handles null additional field', () {
|
||||
final json = {'title': 'Test title', 'status': 'canceled', 'additional': null};
|
||||
|
||||
final model = CanceledModerationModel.fromJson(json);
|
||||
|
||||
expect(model.title, 'Test title');
|
||||
expect(model.status, ModerationStatus.canceled);
|
||||
expect(model.additional, isNull);
|
||||
});
|
||||
|
||||
test('toJson serializes all fields correctly', () {
|
||||
const model = CanceledModerationModel(title: 'Test title', status: ModerationStatus.canceled, additional: ['reason1', 'reason2']);
|
||||
|
||||
final json = model.toJson();
|
||||
|
||||
expect(json['title'], 'Test title');
|
||||
expect(json['status'], 'canceled');
|
||||
expect(json['additional'], ['reason1', 'reason2']);
|
||||
});
|
||||
});
|
||||
|
||||
group('ConsultationRequestModel', () {
|
||||
test('fromJson parses all fields correctly', () {
|
||||
final json = {
|
||||
'rating_place': 5,
|
||||
'education_resource': {'request_state': 'approved'},
|
||||
'cert_photos': [
|
||||
{'url': 'https://example.com/photo1.jpg'},
|
||||
{'url': 'https://example.com/photo2.jpg'},
|
||||
],
|
||||
'canceled_moderation': null,
|
||||
};
|
||||
|
||||
final model = ConsultationRequestModel.fromJson(json);
|
||||
|
||||
expect(model.ratingPlace, 5);
|
||||
expect(model.educationModel.requestState, ModerationStatus.approved);
|
||||
expect(model.photos.length, 2);
|
||||
expect(model.photos[0].url, 'https://example.com/photo1.jpg');
|
||||
expect(model.canceledModeration, isNull);
|
||||
});
|
||||
|
||||
test('fromJson handles null ratingPlace and canceledModeration', () {
|
||||
final json = {
|
||||
'education_resource': {'request_state': 'created'},
|
||||
'cert_photos': [],
|
||||
};
|
||||
|
||||
final model = ConsultationRequestModel.fromJson(json);
|
||||
|
||||
expect(model.ratingPlace, isNull);
|
||||
expect(model.canceledModeration, isNull);
|
||||
expect(model.photos, isEmpty);
|
||||
});
|
||||
|
||||
test('fromJson handles canceledModeration', () {
|
||||
final json = {
|
||||
'rating_place': 10,
|
||||
'education_resource': {'request_state': 'canceled'},
|
||||
'cert_photos': [
|
||||
{'url': 'https://example.com/photo.jpg'},
|
||||
],
|
||||
'canceled_moderation': {
|
||||
'title': 'Rejected',
|
||||
'status': 'canceled',
|
||||
'additional': ['reason1'],
|
||||
},
|
||||
};
|
||||
|
||||
final model = ConsultationRequestModel.fromJson(json);
|
||||
|
||||
expect(model.ratingPlace, 10);
|
||||
expect(model.canceledModeration, isNotNull);
|
||||
expect(model.canceledModeration!.title, 'Rejected');
|
||||
expect(model.canceledModeration!.status, ModerationStatus.canceled);
|
||||
});
|
||||
|
||||
test('toJson serializes ConsultationRequestModel correctly', () {
|
||||
const educationModel = EducationModel(requestState: ModerationStatus.approved);
|
||||
const photos = [CertPhotosModel(url: 'https://example.com/photo1.jpg'), CertPhotosModel(url: 'https://example.com/photo2.jpg')];
|
||||
const model = ConsultationRequestModel(ratingPlace: 5, educationModel: educationModel, photos: photos, canceledModeration: null);
|
||||
|
||||
final json = model.toJson();
|
||||
|
||||
expect(json['rating_place'], 5);
|
||||
expect(json.containsKey('education_resource'), isTrue);
|
||||
expect(json.containsKey('cert_photos'), isTrue);
|
||||
expect((json['cert_photos'] as List).length, 2);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import 'package:eatvio_app/features/consultant/become_consultant/models/consultant_cert_photo_model.dart';
|
||||
import 'package:eatvio_app/features/consultant/become_consultant/models/consultant_profile_model.dart';
|
||||
import 'package:eatvio_app/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_state.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
group('RegisterConsultantState', () {
|
||||
test('creates instance with default values', () {
|
||||
const consultant = ConsultantProfileModel();
|
||||
const state = RegisterConsultantState(
|
||||
consultant: consultant,
|
||||
acceptOffer: false,
|
||||
loading: false,
|
||||
requestModel: null,
|
||||
);
|
||||
|
||||
expect(state.consultant, consultant);
|
||||
expect(state.acceptOffer, false);
|
||||
expect(state.loading, false);
|
||||
expect(state.requestModel, isNull);
|
||||
expect(state.certPhotos, isEmpty);
|
||||
});
|
||||
|
||||
test('creates instance with custom values', () {
|
||||
const consultant = ConsultantProfileModel();
|
||||
const certPhoto = ConsultantCertPhotoModel(
|
||||
id: 1,
|
||||
group: 'user_certs',
|
||||
originalName: 'photo.jpg',
|
||||
path: '/path/to/photo.jpg',
|
||||
size: 1024,
|
||||
url: 'https://example.com/photo.jpg',
|
||||
);
|
||||
const state = RegisterConsultantState(
|
||||
consultant: consultant,
|
||||
acceptOffer: true,
|
||||
loading: true,
|
||||
requestModel: null,
|
||||
certPhotos: [certPhoto],
|
||||
);
|
||||
|
||||
expect(state.acceptOffer, true);
|
||||
expect(state.loading, true);
|
||||
expect(state.certPhotos.length, 1);
|
||||
expect(state.certPhotos.first.url, 'https://example.com/photo.jpg');
|
||||
});
|
||||
|
||||
test('copyWith creates new instance with updated values', () {
|
||||
const consultant = ConsultantProfileModel();
|
||||
const originalState = RegisterConsultantState(
|
||||
consultant: consultant,
|
||||
acceptOffer: false,
|
||||
loading: false,
|
||||
requestModel: null,
|
||||
);
|
||||
|
||||
final updatedState = originalState.copyWith(
|
||||
acceptOffer: true,
|
||||
loading: true,
|
||||
);
|
||||
|
||||
expect(updatedState.acceptOffer, true);
|
||||
expect(updatedState.loading, true);
|
||||
expect(updatedState.consultant, originalState.consultant);
|
||||
expect(updatedState.requestModel, originalState.requestModel);
|
||||
});
|
||||
|
||||
test('copyWith updates certPhotos', () {
|
||||
const consultant = ConsultantProfileModel();
|
||||
const certPhoto1 = ConsultantCertPhotoModel(
|
||||
id: 1,
|
||||
group: 'user_certs',
|
||||
originalName: 'photo1.jpg',
|
||||
path: '/path/to/photo1.jpg',
|
||||
size: 1024,
|
||||
url: 'https://example.com/photo1.jpg',
|
||||
);
|
||||
const certPhoto2 = ConsultantCertPhotoModel(
|
||||
id: 2,
|
||||
group: 'user_certs',
|
||||
originalName: 'photo2.jpg',
|
||||
path: '/path/to/photo2.jpg',
|
||||
size: 2048,
|
||||
url: 'https://example.com/photo2.jpg',
|
||||
);
|
||||
const originalState = RegisterConsultantState(
|
||||
consultant: consultant,
|
||||
acceptOffer: false,
|
||||
loading: false,
|
||||
requestModel: null,
|
||||
certPhotos: [certPhoto1],
|
||||
);
|
||||
|
||||
final updatedState = originalState.copyWith(
|
||||
certPhotos: [certPhoto1, certPhoto2],
|
||||
);
|
||||
|
||||
expect(updatedState.certPhotos.length, 2);
|
||||
expect(updatedState.certPhotos.first.url, 'https://example.com/photo1.jpg');
|
||||
expect(updatedState.certPhotos.last.url, 'https://example.com/photo2.jpg');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user