Merge pull request 'BUILD 94' (#884) from main into test-flight

Reviewed-on: eatvio/eatvio_app#884
This commit is contained in:
mso7nueh
2026-01-27 14:58:13 +00:00
190 changed files with 21356 additions and 1955 deletions

View File

@@ -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"

View File

@@ -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/"

View 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()

View File

@@ -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

View File

@@ -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'
}

View File

@@ -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>()),
];
/// Делегаты локализации

View File

@@ -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);
};
}
}

View File

@@ -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

View File

@@ -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()));
}
}

View File

@@ -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);
// Не пробрасываем ошибку дальше, так как это не критичная операция
}
}
}

View File

@@ -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();

View File

@@ -1 +1 @@
enum PrefsKeys { hasAnonymousTokenBeenSent, welcomeScreenWasShowed, registrationDate, marketingBottomSheetHasBeenShown }
enum PrefsKeys { hasAnonymousTokenBeenSent, welcomeScreenWasShowed, registrationDate, marketingBottomSheetHasBeenShown, isEmailVerificationSkipped }

View File

@@ -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()),

View File

@@ -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(

View File

@@ -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;
}
},

View File

@@ -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');
}
}

View 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);
}

View 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,
);
}
}

View File

@@ -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();
}
}

View File

@@ -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),
),
),
),
);
},

View File

@@ -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);
}

View File

@@ -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,
);
},
);
}
}

View File

@@ -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>(

View File

@@ -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),
],
),
),

View File

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

View File

@@ -15,6 +15,7 @@ abstract class CreateRecipeState with _$CreateRecipeState {
String? servingsValidationError,
String? energyPerServingValidationError,
String? imageValidationError,
String? stepsValidationError,
}) = _CreateRecipeState;
}

View File

@@ -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 {}

View File

@@ -75,6 +75,7 @@ class _IngredientsWidgetState extends State<IngredientsWidget> {
ListTile(
key: AppKeys.recipeCreateScreenAddIngredientButton,
onTap: () {
FocusManager.instance.primaryFocus?.unfocus();
nameController.clear();
gramsController.clear();
pcsController.clear();

View File

@@ -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,

View File

@@ -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);
}

View File

@@ -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) {

View File

@@ -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;

View File

@@ -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,

View File

@@ -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),
],
);
}

View File

@@ -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),

View File

@@ -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,

View File

@@ -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) {

View File

@@ -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: [

View File

@@ -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(

View File

@@ -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 {

View File

@@ -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,
);
},
),
),

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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(),
);
},
);

View File

@@ -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: [

View File

@@ -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,
);
},
),
),

View File

@@ -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),
),
),
],
),

View File

@@ -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);
}

View File

@@ -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());
}
}

View File

@@ -23,4 +23,6 @@ abstract class VerifyEmailState with _$VerifyEmailState {
const factory VerifyEmailState.changeEmailSuccess() = VerifyEmailStateChangeEmailSuccess;
const factory VerifyEmailState.changeEmailError() = VerifyEmailStateChangeEmailError;
const factory VerifyEmailState.skipped() = VerifyEmailStateSkipped;
}

View File

@@ -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();
},
),
],
),
),

View File

@@ -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("Пожаловаться"),

View File

@@ -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("Пожаловаться"),

View File

@@ -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> {

View File

@@ -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": "Комментарий"
}

View File

@@ -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": "Комментарий"
}

View File

@@ -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

View File

@@ -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));
});
});
}

View File

@@ -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');
});
});
}

View File

@@ -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();
}
}
});
});
}

View File

@@ -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');
});
});
}

View File

@@ -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);
});
});
}

View File

@@ -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);
});
});
}

View File

@@ -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);
});
});
}

View File

@@ -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>());
});
});
}

View File

@@ -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 {

View File

@@ -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);
}
});
});
}

View 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)'));
});
});
}

View File

@@ -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} символов');
});
});
});
}

View File

@@ -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);
});
});
}

View File

@@ -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();

View File

@@ -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);
});

View File

@@ -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();

View 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);
});
});
}

View 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');
});
});
}

View File

@@ -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');
});
});
}

View File

@@ -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);
});
});
}

View 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');
}
});
});
}

View File

@@ -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);
});
});
}

View File

@@ -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);
});
});
}

View File

@@ -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);
});
});
}

View File

@@ -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')),
),
),
),

View File

@@ -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);
});
});
}

View File

@@ -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);
});
});
}

View File

@@ -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);
});
});
}

View File

@@ -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);
});
});
}

View File

@@ -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);
});
});
}

View File

@@ -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');
});
});
}

View File

@@ -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);
});
});
}

View File

@@ -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

View File

@@ -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);
});
});
}

View File

@@ -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);
});
});
}

View File

@@ -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');
});
});
}

View File

@@ -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);
});
});
}

View File

@@ -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);
});
});
}

View File

@@ -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