forked from eatvio/eatvio_app
Compare commits
79 Commits
issue-975
...
issue-1037
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1b03a0d5e1 | ||
|
|
0208ed753a | ||
|
|
6265e865e9 | ||
| 1550b7ea0b | |||
|
|
db10895ebb | ||
|
|
a7361d3ab1 | ||
| 1c54063a35 | |||
| efae7fc958 | |||
|
|
90ac4e750d | ||
|
|
6d7419c283 | ||
| caf45a9050 | |||
|
|
0a5e7d95c4 | ||
|
|
85482173ad | ||
| 65f7992d40 | |||
|
|
52a01bee12 | ||
| 179fc0ad99 | |||
|
|
8494e61eec | ||
|
|
5c2458ecad | ||
| b63bab8bf5 | |||
| 7790014726 | |||
|
|
0d588bcd57 | ||
| b679e90ba2 | |||
|
|
6b5442eb87 | ||
| 2474c5bea3 | |||
|
|
478a53674a | ||
| 7f278d0f59 | |||
|
|
53426c858c | ||
| 88f0cde88f | |||
|
|
a5fcf62524 | ||
| a2e41a2ff5 | |||
|
|
5b1beafbab | ||
| 4eadecd6f2 | |||
|
|
6292db07a1 | ||
| ab29b6b329 | |||
|
|
af5d182a79 | ||
|
|
04de956769 | ||
| 191bb74aa4 | |||
|
|
3210f38d4a | ||
| 27a98a496d | |||
| d8ecc7f3b1 | |||
| 063da03d7e | |||
| 6b5927a99e | |||
| afab72f7bc | |||
| 2770ff0e80 | |||
| 1200021bb6 | |||
|
|
9e8b8b89fc | ||
| a24d052193 | |||
|
|
9de0efa28e | ||
| 37504aa292 | |||
| c8588a2e1f | |||
|
|
e0cab89126 | ||
|
|
954d4955da | ||
| e5086d7fbc | |||
| 0d93768dc3 | |||
|
|
8c5fc8d827 | ||
|
|
2d6b042d45 | ||
| d56aa45c92 | |||
|
|
b3d0a26667 | ||
| 8835d65944 | |||
|
|
cc9aa6bffb | ||
| a15e8b78a5 | |||
|
|
0667ab81a8 | ||
|
|
1dc60eec64 | ||
|
|
69f0f1e919 | ||
|
|
a6fe6afb80 | ||
|
|
c8c8ade083 | ||
|
|
037f153273 | ||
| 6a17bf3a95 | |||
|
|
2cccc9e253 | ||
| dcf7b25989 | |||
| d6b57ef34d | |||
| 45f20cc903 | |||
| 746b9b6d28 | |||
|
|
2dbd4a455f | ||
| c7514e2cbf | |||
| f1abea70c5 | |||
| e4d397b478 | |||
| 9c5c6df751 | |||
|
|
48a984f5e9 |
@@ -59,12 +59,16 @@ jobs:
|
|||||||
- name: Flutter build
|
- name: Flutter build
|
||||||
run: |
|
run: |
|
||||||
flutter --version
|
flutter --version
|
||||||
|
flutter config --no-enable-swift-package-manager
|
||||||
|
rm -rf ios/Podfile.lock
|
||||||
flutter clean
|
flutter clean
|
||||||
flutter pub get
|
flutter pub get
|
||||||
flutter pub run build_runner build --delete-conflicting-outputs
|
flutter pub run build_runner build --delete-conflicting-outputs
|
||||||
flutter build ios --config-only --release
|
|
||||||
cd ios
|
cd ios
|
||||||
pod install
|
pod install --repo-update
|
||||||
|
cd ..
|
||||||
|
flutter build ios --config-only --release
|
||||||
|
cd ios
|
||||||
fastlane beta
|
fastlane beta
|
||||||
env:
|
env:
|
||||||
LANG: en_US.UTF-8
|
LANG: en_US.UTF-8
|
||||||
|
|||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -24,4 +24,5 @@ coverage/
|
|||||||
backend/
|
backend/
|
||||||
.fvm/
|
.fvm/
|
||||||
.fvmrc
|
.fvmrc
|
||||||
/eatvio
|
/eatvio
|
||||||
|
*.code-workspace
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
# Current Context
|
# Current Context
|
||||||
|
|
||||||
## Project Status
|
## Project Status
|
||||||
Memory Bank initialized on 2025-10-16. This is a mature Flutter application (v1.3.0+79) currently in production.
|
Memory Bank initialized on 2025-10-16. This is a mature Flutter application (v1.3.24+106) currently in production.
|
||||||
|
|
||||||
## Recent Activity
|
## Recent Activity
|
||||||
- Memory Bank initialization completed
|
- Memory Bank initialization completed
|
||||||
@@ -9,10 +9,19 @@ Memory Bank initialized on 2025-10-16. This is a mature Flutter application (v1.
|
|||||||
- Architecture and technology stack catalogued
|
- Architecture and technology stack catalogued
|
||||||
|
|
||||||
## Current Focus
|
## Current Focus
|
||||||
Ready for development tasks. No active work in progress.
|
Виджет видеоплеера ленты (`lib/features/feed/presentation/widgets/feed_video_player_widget.dart`)
|
||||||
|
мигрирован с `webview_flutter` v4 на `flutter_inappwebview` 6.1.5. Цель — фикс
|
||||||
|
fullscreen-кнопки внутри embed-плеера (Rutube/YouTube): `webview_flutter` не реализует
|
||||||
|
Android `WebChromeClient.onShowCustomView/onHideCustomView`, а `flutter_inappwebview`
|
||||||
|
делает это нативно (`InAppWebViewChromeClient`). Паритет UX сохранён (превью → play →
|
||||||
|
скрытая загрузка → fade-in по Rutube `player:canPlay`, плюс страховочный таймер 4с
|
||||||
|
для провайдеров без этого протокола). Добавлена ориентация landscape на fullscreen.
|
||||||
|
Остальные 4 экрана на `webview_flutter` не трогались; обе зависимости в `pubspec.yaml`.
|
||||||
|
|
||||||
## Known Issues
|
## Known Issues
|
||||||
None documented at initialization.
|
- 2 теста в `test/features/feed/` (`post_feed_pagination_model_test`,
|
||||||
|
`post_feed_mapper_test`) падают на ветке `personalized_feed` — пре-существующие,
|
||||||
|
связаны с fixtures/маппингом, не с видеоплеером.
|
||||||
|
|
||||||
## Next Steps
|
## Next Steps
|
||||||
Awaiting user direction for:
|
Awaiting user direction for:
|
||||||
|
|||||||
@@ -78,12 +78,7 @@
|
|||||||
<data android:host="eatvio.com" />
|
<data android:host="eatvio.com" />
|
||||||
<data android:host="www.eatvio.com" />
|
<data android:host="www.eatvio.com" />
|
||||||
|
|
||||||
<!-- Development/Subdomains -->
|
|
||||||
<data android:host="*.eatvio.ru" />
|
|
||||||
<data android:host="*.eatvio.com" />
|
|
||||||
|
|
||||||
<!-- Simplify patterns to allow navigation within the app for all matching hosts -->
|
|
||||||
<data android:pathPattern="/.*" />
|
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
<!-- Don't delete the meta-data below.
|
<!-- Don't delete the meta-data below.
|
||||||
|
|||||||
@@ -47,8 +47,6 @@ post_install do |installer|
|
|||||||
## dart: PermissionGroup.photos
|
## dart: PermissionGroup.photos
|
||||||
'PERMISSION_PHOTOS=1',
|
'PERMISSION_PHOTOS=1',
|
||||||
'PERMISSION_NOTIFICATIONS=1',
|
'PERMISSION_NOTIFICATIONS=1',
|
||||||
## dart: PermissionGroup.mediaLibrary
|
|
||||||
'PERMISSION_MEDIA_LIBRARY=1',
|
|
||||||
]
|
]
|
||||||
|
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -31,8 +31,6 @@
|
|||||||
<key>NSAllowsArbitraryLoads</key>
|
<key>NSAllowsArbitraryLoads</key>
|
||||||
<true/>
|
<true/>
|
||||||
</dict>
|
</dict>
|
||||||
<key>NSAppleMusicUsageDescription</key>
|
|
||||||
<string>This permission is not needed by the app, but it is required by an underlying API. If you see this dialog, contact us.</string>
|
|
||||||
<key>NSCameraUsageDescription</key>
|
<key>NSCameraUsageDescription</key>
|
||||||
<string>Предоставление доступа позволит Вам делать фотографии и добавлять к записям</string>
|
<string>Предоставление доступа позволит Вам делать фотографии и добавлять к записям</string>
|
||||||
<key>NSMicrophoneUsageDescription</key>
|
<key>NSMicrophoneUsageDescription</key>
|
||||||
|
|||||||
@@ -8,6 +8,8 @@
|
|||||||
<array>
|
<array>
|
||||||
<string>applinks:eatvio.ru</string>
|
<string>applinks:eatvio.ru</string>
|
||||||
<string>applinks:www.eatvio.ru</string>
|
<string>applinks:www.eatvio.ru</string>
|
||||||
|
<string>applinks:eatvio.com</string>
|
||||||
|
<string>applinks:www.eatvio.com</string>
|
||||||
<string>applinks:5399b03636104c6d920b87c1ad329254.oauth.yandex.ru</string>
|
<string>applinks:5399b03636104c6d920b87c1ad329254.oauth.yandex.ru</string>
|
||||||
</array>
|
</array>
|
||||||
</dict>
|
</dict>
|
||||||
|
|||||||
@@ -265,7 +265,15 @@ class OtherRoutes {
|
|||||||
),
|
),
|
||||||
|
|
||||||
// Consultant routes
|
// Consultant routes
|
||||||
GoRoute(path: RouteConstants.becomeConsultantScreen, name: 'becomeConsultantScreen', builder: (context, state) => const BecomeConsultantScreen()),
|
GoRoute(
|
||||||
|
path: RouteConstants.becomeConsultantScreen,
|
||||||
|
name: 'becomeConsultantScreen',
|
||||||
|
builder: (context, state) {
|
||||||
|
final data = state.extra as Map<String, dynamic>?;
|
||||||
|
final slug = data?['slug'] as String?;
|
||||||
|
return BecomeConsultantScreen(slug: slug);
|
||||||
|
},
|
||||||
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: RouteConstants.stepperBecomeConsultantScreen,
|
path: RouteConstants.stepperBecomeConsultantScreen,
|
||||||
name: 'stepperBecomeConsultantScreen',
|
name: 'stepperBecomeConsultantScreen',
|
||||||
@@ -273,7 +281,12 @@ class OtherRoutes {
|
|||||||
final data = state.extra as Map<String, dynamic>;
|
final data = state.extra as Map<String, dynamic>;
|
||||||
final slug = data['slug'] as String?;
|
final slug = data['slug'] as String?;
|
||||||
final isCanceled = data['isCanceled'] as bool?;
|
final isCanceled = data['isCanceled'] as bool?;
|
||||||
return StepperBecomeConsultantScreen(slug: slug, isCanceled: isCanceled);
|
final isNewRegistration = data['isNewRegistration'] as bool? ?? false;
|
||||||
|
return StepperBecomeConsultantScreen(
|
||||||
|
slug: slug,
|
||||||
|
isCanceled: isCanceled,
|
||||||
|
isNewRegistration: isNewRegistration,
|
||||||
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
GoRoute(path: RouteConstants.calendarScreen, name: 'calendarScreen', builder: (context, state) => const CalendarScreen()),
|
GoRoute(path: RouteConstants.calendarScreen, name: 'calendarScreen', builder: (context, state) => const CalendarScreen()),
|
||||||
|
|||||||
@@ -38,13 +38,16 @@ class WsManager {
|
|||||||
StreamSubscription? _eventSubscription;
|
StreamSubscription? _eventSubscription;
|
||||||
StreamSubscription? _connectionEstablishedSubscription;
|
StreamSubscription? _connectionEstablishedSubscription;
|
||||||
|
|
||||||
|
Stream<ChannelReadEvent>? _directChatStream;
|
||||||
|
Stream<ChannelReadEvent>? _clientTypingStream;
|
||||||
|
|
||||||
Stream<ChannelReadEvent>? get newMessagesStream => _privateMessagesChannel?.bind('App\\Events\\ChatMessageUpdated');
|
Stream<ChannelReadEvent>? get newMessagesStream => _privateMessagesChannel?.bind('App\\Events\\ChatMessageUpdated');
|
||||||
|
|
||||||
Stream<ChannelReadEvent>? get newNotificationsStream => _privateNotificationChannel?.bind('Illuminate\\Notifications\\Events\\BroadcastNotificationCreated');
|
Stream<ChannelReadEvent>? get newNotificationsStream => _privateNotificationChannel?.bind('Illuminate\\Notifications\\Events\\BroadcastNotificationCreated');
|
||||||
|
|
||||||
Stream<ChannelReadEvent>? get directChatStream => _privateDirectChatChannel?.bind('App\\Events\\ChatMessageUpdated');
|
Stream<ChannelReadEvent>? get directChatStream => _directChatStream;
|
||||||
|
|
||||||
Stream<ChannelReadEvent>? get clientTypingStream => _privateDirectChatChannel?.bind('client-typing');
|
Stream<ChannelReadEvent>? get clientTypingStream => _clientTypingStream;
|
||||||
|
|
||||||
Future<void> initialize() async {
|
Future<void> initialize() async {
|
||||||
if (client == null) {
|
if (client == null) {
|
||||||
@@ -107,6 +110,8 @@ class WsManager {
|
|||||||
if (data == PusherChannelsClientLifeCycleState.establishedConnection) {
|
if (data == PusherChannelsClientLifeCycleState.establishedConnection) {
|
||||||
_unsubscribe();
|
_unsubscribe();
|
||||||
_subscribe();
|
_subscribe();
|
||||||
|
_privateDirectChatChannel?.subscribe();
|
||||||
|
_presenceDirectChatChannel?.subscribe();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
_eventSubscription = client?.eventStream.listen((event) {
|
_eventSubscription = client?.eventStream.listen((event) {
|
||||||
@@ -170,6 +175,7 @@ class WsManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> initPrivateDirectChatChannel(String chatId) async {
|
Future<void> initPrivateDirectChatChannel(String chatId) async {
|
||||||
|
_privateDirectChatChannel?.unsubscribe();
|
||||||
final token = await TokenStorage.getTokenFromStorage();
|
final token = await TokenStorage.getTokenFromStorage();
|
||||||
_privateDirectChatChannel = client?.privateChannel(
|
_privateDirectChatChannel = client?.privateChannel(
|
||||||
'$directChatChannelName.$chatId',
|
'$directChatChannelName.$chatId',
|
||||||
@@ -180,6 +186,8 @@ class WsManager {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
_directChatStream = _privateDirectChatChannel?.bind('App\\Events\\ChatMessageUpdated');
|
||||||
|
_clientTypingStream = _privateDirectChatChannel?.bind('client-typing');
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> initPresenceDirectChatChannel(String chatId) async {
|
Future<void> initPresenceDirectChatChannel(String chatId) async {
|
||||||
@@ -299,6 +307,8 @@ class WsManager {
|
|||||||
|
|
||||||
void unsubscribeDirectChatChannel() {
|
void unsubscribeDirectChatChannel() {
|
||||||
_privateDirectChatChannel?.unsubscribe();
|
_privateDirectChatChannel?.unsubscribe();
|
||||||
|
_directChatStream = null;
|
||||||
|
_clientTypingStream = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
void unsubscribePresenceDirectChatChannel() {
|
void unsubscribePresenceDirectChatChannel() {
|
||||||
@@ -331,6 +341,8 @@ class WsManager {
|
|||||||
_privateDirectChatChannel = null;
|
_privateDirectChatChannel = null;
|
||||||
_privateMessagesChannel = null;
|
_privateMessagesChannel = null;
|
||||||
_privateNotificationChannel = null;
|
_privateNotificationChannel = null;
|
||||||
|
_directChatStream = null;
|
||||||
|
_clientTypingStream = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import 'package:eatvio_app/core/utils/toasts.dart';
|
|||||||
import 'package:eatvio_app/core/widgets/show_error_dialog.dart';
|
import 'package:eatvio_app/core/widgets/show_error_dialog.dart';
|
||||||
import 'package:eatvio_app/features/blog_post_create/bloc/blog_post_create_cubit/blog_post_create_cubit.dart';
|
import 'package:eatvio_app/features/blog_post_create/bloc/blog_post_create_cubit/blog_post_create_cubit.dart';
|
||||||
import 'package:eatvio_app/features/complaining/presentation/complaining_bottom_sheet.dart';
|
import 'package:eatvio_app/features/complaining/presentation/complaining_bottom_sheet.dart';
|
||||||
|
import 'package:eatvio_app/features/feed/presentation/bloc/feed_cubit/feed_cubit.dart';
|
||||||
import 'package:eatvio_app/features/feed/models/diets_pagination_model.dart';
|
import 'package:eatvio_app/features/feed/models/diets_pagination_model.dart';
|
||||||
import 'package:eatvio_app/features/feed/models/feed_blog_post_model.dart';
|
import 'package:eatvio_app/features/feed/models/feed_blog_post_model.dart';
|
||||||
import 'package:eatvio_app/features/feed/models/recipes_model.dart';
|
import 'package:eatvio_app/features/feed/models/recipes_model.dart';
|
||||||
@@ -13,9 +14,11 @@ import 'package:eatvio_app/features/feed/presentation/bloc/diets_cubit/diets_cub
|
|||||||
import 'package:eatvio_app/features/feed/presentation/bloc/recipes_cubit/recipes_cubit.dart';
|
import 'package:eatvio_app/features/feed/presentation/bloc/recipes_cubit/recipes_cubit.dart';
|
||||||
import 'package:eatvio_app/features/menu/presentation/blocked_user_cubit/blocked_users_cubit.dart';
|
import 'package:eatvio_app/features/menu/presentation/blocked_user_cubit/blocked_users_cubit.dart';
|
||||||
import 'package:eatvio_app/features/my_plans/presentation/cubit/my_plans_cubit.dart';
|
import 'package:eatvio_app/features/my_plans/presentation/cubit/my_plans_cubit.dart';
|
||||||
|
import 'package:eatvio_app/features/profile/presentation/bloc/profile_cubit/profile_cubit.dart';
|
||||||
import 'package:eatvio_app/generated/l10n.dart';
|
import 'package:eatvio_app/generated/l10n.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:flutter_svg/flutter_svg.dart';
|
import 'package:flutter_svg/flutter_svg.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:share_plus/share_plus.dart';
|
import 'package:share_plus/share_plus.dart';
|
||||||
@@ -30,6 +33,7 @@ class CustomPopup extends StatelessWidget {
|
|||||||
final Recipe? recipe;
|
final Recipe? recipe;
|
||||||
final Diet? diet;
|
final Diet? diet;
|
||||||
final String userSlug;
|
final String userSlug;
|
||||||
|
final VoidCallback? onPostsChanged;
|
||||||
|
|
||||||
const CustomPopup({
|
const CustomPopup({
|
||||||
super.key,
|
super.key,
|
||||||
@@ -41,7 +45,8 @@ class CustomPopup extends StatelessWidget {
|
|||||||
this.feedBlogPostModel,
|
this.feedBlogPostModel,
|
||||||
this.recipe,
|
this.recipe,
|
||||||
this.diet,
|
this.diet,
|
||||||
required this.userSlug
|
required this.userSlug,
|
||||||
|
this.onPostsChanged,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -103,7 +108,8 @@ class CustomPopup extends StatelessWidget {
|
|||||||
if (complainedContentType == ComplainedContent.posts) {
|
if (complainedContentType == ComplainedContent.posts) {
|
||||||
addItem('assets/icons/edit_paint.svg', 'Редактировать', () async {
|
addItem('assets/icons/edit_paint.svg', 'Редактировать', () async {
|
||||||
if (complainedContentType == ComplainedContent.posts) {
|
if (complainedContentType == ComplainedContent.posts) {
|
||||||
GoRouter.of(context).pushNamed('blogPostCreateScreen', extra: feedBlogPostModel);
|
await GoRouter.of(context).pushNamed('blogPostCreateScreen', extra: feedBlogPostModel);
|
||||||
|
onPostsChanged?.call();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -114,7 +120,14 @@ class CustomPopup extends StatelessWidget {
|
|||||||
showErrorDialog(context, title: S.current.could_not_delete_blog_post);
|
showErrorDialog(context, title: S.current.could_not_delete_blog_post);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
showSuccessToast('Пост удален, обновите ленту');
|
onPostsChanged?.call();
|
||||||
|
ProfileCubit.myProfileCubit?.reloadBlogPosts();
|
||||||
|
if (context.mounted) {
|
||||||
|
try {
|
||||||
|
context.read<FeedCubit>().loadFeedBlogPostList();
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
showSuccessToast('Пост удален');
|
||||||
} else if (complainedContentType == ComplainedContent.recipe) {
|
} else if (complainedContentType == ComplainedContent.recipe) {
|
||||||
final result = await sl<RecipesCubit>().deleteRecipe(slug!);
|
final result = await sl<RecipesCubit>().deleteRecipe(slug!);
|
||||||
if (!result && context.mounted) {
|
if (!result && context.mounted) {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import 'package:cached_network_image/cached_network_image.dart';
|
import 'package:dismissible_page/dismissible_page.dart';
|
||||||
import 'package:eatvio_app/core/app_config.dart';
|
import 'package:eatvio_app/core/app_config.dart';
|
||||||
import 'package:eatvio_app/core/enums/complained_content.dart';
|
import 'package:eatvio_app/core/enums/complained_content.dart';
|
||||||
import 'package:eatvio_app/core/utils/date_time_utils.dart';
|
import 'package:eatvio_app/core/utils/date_time_utils.dart';
|
||||||
@@ -9,7 +9,10 @@ import 'package:eatvio_app/features/blog_post/presentation/cubit/blog_post_cubit
|
|||||||
import 'package:eatvio_app/features/blog_post/presentation/cubit/blog_post_state.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';
|
import 'package:eatvio_app/core/comments/comments_cubit/comments_cubit.dart';
|
||||||
import 'package:eatvio_app/core/comments/comments_cubit/comments_state.dart';
|
import 'package:eatvio_app/core/comments/comments_cubit/comments_state.dart';
|
||||||
|
import 'package:eatvio_app/features/chat/presentation/pages/photo_view_screen.dart';
|
||||||
|
import 'package:eatvio_app/features/feed/models/post_feed_mapper.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_cubit.dart';
|
||||||
|
import 'package:eatvio_app/features/feed/presentation/widgets/blog_post_media_content.dart';
|
||||||
import 'package:eatvio_app/features/feed/presentation/widgets/comments_widget.dart';
|
import 'package:eatvio_app/features/feed/presentation/widgets/comments_widget.dart';
|
||||||
import 'package:eatvio_app/core/locator_service.dart';
|
import 'package:eatvio_app/core/locator_service.dart';
|
||||||
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
||||||
@@ -33,6 +36,7 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
|
|||||||
final CommentsCubit commentsCubit = sl<CommentsCubit>();
|
final CommentsCubit commentsCubit = sl<CommentsCubit>();
|
||||||
final textEditingController = TextEditingController();
|
final textEditingController = TextEditingController();
|
||||||
int? replyId;
|
int? replyId;
|
||||||
|
int currentGalleryIndex = 0;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -40,6 +44,10 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
|
|||||||
super.initState();
|
super.initState();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _openGallery(List<String> imageUrls, int index, {Object? heroTag}) {
|
||||||
|
context.pushTransparentRoute(rootNavigator: true, PhotoViewScreen(imageUrls: imageUrls, index: index, heroTag: heroTag));
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return BlocProvider(
|
return BlocProvider(
|
||||||
@@ -242,8 +250,30 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
|
|||||||
child: MarkdownHtmlWidget(markdownText: blogPostState.blogPostModel!.description),
|
child: MarkdownHtmlWidget(markdownText: blogPostState.blogPostModel!.description),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
if (blogPostState.blogPostModel?.image.isNotEmpty ?? false)
|
Builder(
|
||||||
CachedNetworkImage(imageUrl: '${sl<AppConfig>().baseUrl}/storage/${blogPostState.blogPostModel?.image ?? ''}'),
|
builder: (context) {
|
||||||
|
final post = blogPostState.blogPostModel;
|
||||||
|
if (post == null) {
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
}
|
||||||
|
final imageUrl = PostFeedMapper.resolveMediaUrl(post.image);
|
||||||
|
final embedUrl = post.videoEmbedUrl?.trim();
|
||||||
|
return BlogPostMediaContent(
|
||||||
|
galleryImages: post.images,
|
||||||
|
imageUrl: imageUrl,
|
||||||
|
videoEmbedUrl: embedUrl != null && embedUrl.isNotEmpty ? embedUrl : null,
|
||||||
|
videoUrlThumb: post.videoUrlThumb != null ? PostFeedMapper.resolveMediaUrl(post.videoUrlThumb) : null,
|
||||||
|
currentGalleryIndex: currentGalleryIndex,
|
||||||
|
heroTag: 'blog-post-screen-${post.id}-cover',
|
||||||
|
onOpenGallery: (urls, index) => _openGallery(urls, index, heroTag: urls.length == 1 ? 'blog-post-screen-${post.id}-cover' : null),
|
||||||
|
onGalleryChanged: (index) {
|
||||||
|
setState(() {
|
||||||
|
currentGalleryIndex = index;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||||
child: Row(
|
child: Row(
|
||||||
|
|||||||
@@ -3,38 +3,195 @@ import 'dart:developer';
|
|||||||
import 'package:eatvio_app/core/error_reporting/report_from.dart';
|
import 'package:eatvio_app/core/error_reporting/report_from.dart';
|
||||||
import 'package:eatvio_app/features/blog_post_create/bloc/blog_post_create_cubit/blog_post_create_state.dart';
|
import 'package:eatvio_app/features/blog_post_create/bloc/blog_post_create_cubit/blog_post_create_state.dart';
|
||||||
import 'package:eatvio_app/features/blog_post_create/repositories/blog_post_repository.dart';
|
import 'package:eatvio_app/features/blog_post_create/repositories/blog_post_repository.dart';
|
||||||
|
import 'package:eatvio_app/features/blog_post_create/validation/blog_post_form_validator.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:flutter_image_compress/flutter_image_compress.dart';
|
import 'package:flutter_image_compress/flutter_image_compress.dart';
|
||||||
|
|
||||||
class BlogPostCreateCubit extends Cubit<BlogPostCreateState> {
|
class BlogPostCreateCubit extends Cubit<BlogPostCreateState> {
|
||||||
final BlogPostRepository blogPostRepository;
|
final BlogPostRepository blogPostRepository;
|
||||||
|
|
||||||
BlogPostCreateCubit({required this.blogPostRepository}) : super(const BlogPostCreateState());
|
BlogPostCreateCubit({required this.blogPostRepository})
|
||||||
|
: super(const BlogPostCreateState());
|
||||||
|
|
||||||
|
void clearTitleError() {
|
||||||
|
if (state.titleError != null || state.formError != null) {
|
||||||
|
emit(state.copyWith(titleError: null, formError: null));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void clearDescriptionError() {
|
||||||
|
if (state.descriptionError != null || state.formError != null) {
|
||||||
|
emit(state.copyWith(descriptionError: null, formError: null));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void clearVideoUrlError() {
|
||||||
|
if (state.videoUrlError != null || state.formError != null) {
|
||||||
|
emit(state.copyWith(videoUrlError: null, formError: null));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void clearImagesError() {
|
||||||
|
if (state.imagesError != null || state.formError != null) {
|
||||||
|
emit(state.copyWith(imagesError: null, formError: null));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void setImagesError(String? error) {
|
||||||
|
if (state.imagesError == error &&
|
||||||
|
state.formError == null &&
|
||||||
|
state.errorMessage == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
emit(
|
||||||
|
state.copyWith(imagesError: error, formError: null, errorMessage: null),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void resetFormErrors() {
|
||||||
|
if (!state.hasValidationErrors &&
|
||||||
|
!state.isSuccess &&
|
||||||
|
state.errorMessage == null &&
|
||||||
|
!state.isLoading) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
emit(const BlogPostCreateState());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validates form fields and emits error messages on [BlogPostCreateState].
|
||||||
|
/// Returns `true` when the form is valid.
|
||||||
|
bool validateForm({
|
||||||
|
required String title,
|
||||||
|
required String description,
|
||||||
|
required String videoUrl,
|
||||||
|
required int imageCount,
|
||||||
|
Iterable<String> imagePaths = const [],
|
||||||
|
}) {
|
||||||
|
final titleError = BlogPostFormValidator.title(title);
|
||||||
|
final descriptionError = BlogPostFormValidator.description(description);
|
||||||
|
final videoUrlError = BlogPostFormValidator.videoUrl(videoUrl);
|
||||||
|
final imagesError =
|
||||||
|
BlogPostFormValidator.galleryCount(imageCount) ??
|
||||||
|
BlogPostFormValidator.imagePaths(imagePaths);
|
||||||
|
final formError = BlogPostFormValidator.contentRequiredMessage(
|
||||||
|
title: title,
|
||||||
|
description: description,
|
||||||
|
videoUrl: videoUrl,
|
||||||
|
imageCount: imageCount,
|
||||||
|
);
|
||||||
|
|
||||||
|
final hasFieldErrors =
|
||||||
|
titleError != null ||
|
||||||
|
descriptionError != null ||
|
||||||
|
videoUrlError != null ||
|
||||||
|
imagesError != null;
|
||||||
|
final resolvedFormError = hasFieldErrors ? null : formError;
|
||||||
|
|
||||||
|
emit(
|
||||||
|
state.copyWith(
|
||||||
|
titleError: titleError,
|
||||||
|
descriptionError: descriptionError,
|
||||||
|
videoUrlError: videoUrlError,
|
||||||
|
imagesError: imagesError,
|
||||||
|
// Form-level message only when individual fields are fine but content is empty.
|
||||||
|
formError: resolvedFormError,
|
||||||
|
isSuccess: false,
|
||||||
|
errorMessage: null,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
return titleError == null &&
|
||||||
|
descriptionError == null &&
|
||||||
|
videoUrlError == null &&
|
||||||
|
imagesError == null &&
|
||||||
|
resolvedFormError == null;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> createBlogPost({
|
||||||
|
String? title,
|
||||||
|
String? videoUrl,
|
||||||
|
String? description,
|
||||||
|
List<XFile> images = const [],
|
||||||
|
}) async {
|
||||||
|
final trimmedTitle = title?.trim() ?? '';
|
||||||
|
final trimmedDescription = description?.trim() ?? '';
|
||||||
|
final trimmedVideoUrl = videoUrl?.trim() ?? '';
|
||||||
|
|
||||||
|
final isValid = validateForm(
|
||||||
|
title: trimmedTitle,
|
||||||
|
description: trimmedDescription,
|
||||||
|
videoUrl: trimmedVideoUrl,
|
||||||
|
imageCount: images.length,
|
||||||
|
imagePaths: images.map((image) => image.path),
|
||||||
|
);
|
||||||
|
if (!isValid) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
Future<bool> createBlogPost({String? title, String? videoUrl, String? description, XFile? image}) async {
|
|
||||||
try {
|
try {
|
||||||
emit(const BlogPostCreateState.loading());
|
emit(
|
||||||
await blogPostRepository.createBlogPost(title, videoUrl, description, image);
|
state.copyWith(isLoading: true, isSuccess: false, errorMessage: null),
|
||||||
emit(const BlogPostCreateState.loaded());
|
);
|
||||||
|
await blogPostRepository.createBlogPost(
|
||||||
|
title: trimmedTitle.isEmpty ? null : trimmedTitle,
|
||||||
|
videoUrl: trimmedVideoUrl.isEmpty ? null : trimmedVideoUrl,
|
||||||
|
description: trimmedDescription.isEmpty ? null : trimmedDescription,
|
||||||
|
images: images,
|
||||||
|
);
|
||||||
|
emit(const BlogPostCreateState(isSuccess: true));
|
||||||
return true;
|
return true;
|
||||||
} catch (error, stackTrace) {
|
} catch (error, stackTrace) {
|
||||||
log('BlogPostCreateCubit: $error', stackTrace: stackTrace);
|
log('BlogPostCreateCubit: $error', stackTrace: stackTrace);
|
||||||
await reportFrom(error, stackTrace, this);
|
await reportFrom(error, stackTrace, this);
|
||||||
emit(BlogPostCreateState.error(errorMessage: error.toString()));
|
emit(BlogPostCreateState(errorMessage: error.toString()));
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<bool> updateBlogPost({required int id, String? title, String? videoUrl, String? description, XFile? image, String? oldImageUrl}) async {
|
Future<bool> updateBlogPost({
|
||||||
|
required int id,
|
||||||
|
String? title,
|
||||||
|
String? videoUrl,
|
||||||
|
String? description,
|
||||||
|
List<int> existingMediaIds = const [],
|
||||||
|
List<XFile> newImages = const [],
|
||||||
|
List<int> mediaIdsToDelete = const [],
|
||||||
|
}) async {
|
||||||
|
final trimmedTitle = title?.trim() ?? '';
|
||||||
|
final trimmedDescription = description?.trim() ?? '';
|
||||||
|
final trimmedVideoUrl = videoUrl?.trim() ?? '';
|
||||||
|
final imageCount = existingMediaIds.length + newImages.length;
|
||||||
|
|
||||||
|
final isValid = validateForm(
|
||||||
|
title: trimmedTitle,
|
||||||
|
description: trimmedDescription,
|
||||||
|
videoUrl: trimmedVideoUrl,
|
||||||
|
imageCount: imageCount,
|
||||||
|
imagePaths: newImages.map((image) => image.path),
|
||||||
|
);
|
||||||
|
if (!isValid) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
emit(const BlogPostCreateState.loading());
|
emit(
|
||||||
await blogPostRepository.updateBlogPost(id, title, videoUrl, description, image, oldImageUrl);
|
state.copyWith(isLoading: true, isSuccess: false, errorMessage: null),
|
||||||
emit(const BlogPostCreateState.loaded());
|
);
|
||||||
|
await blogPostRepository.updateBlogPost(
|
||||||
|
id: id,
|
||||||
|
title: trimmedTitle,
|
||||||
|
videoUrl: trimmedVideoUrl,
|
||||||
|
description: trimmedDescription,
|
||||||
|
existingMediaIds: existingMediaIds,
|
||||||
|
newImages: newImages,
|
||||||
|
mediaIdsToDelete: mediaIdsToDelete,
|
||||||
|
);
|
||||||
|
emit(const BlogPostCreateState(isSuccess: true));
|
||||||
return true;
|
return true;
|
||||||
} catch (error, stackTrace) {
|
} catch (error, stackTrace) {
|
||||||
log('BlogPostCreateCubit: $error', stackTrace: stackTrace);
|
log('BlogPostCreateCubit: $error', stackTrace: stackTrace);
|
||||||
await reportFrom(error, stackTrace, this);
|
await reportFrom(error, stackTrace, this);
|
||||||
emit(BlogPostCreateState.error(errorMessage: error.toString()));
|
emit(BlogPostCreateState(errorMessage: error.toString()));
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,13 +4,23 @@ part 'blog_post_create_state.freezed.dart';
|
|||||||
|
|
||||||
@freezed
|
@freezed
|
||||||
abstract class BlogPostCreateState with _$BlogPostCreateState {
|
abstract class BlogPostCreateState with _$BlogPostCreateState {
|
||||||
const factory BlogPostCreateState() = _BlogPostCreateState;
|
const BlogPostCreateState._();
|
||||||
|
|
||||||
const factory BlogPostCreateState.loading() = BlogPostCreateStateLoading;
|
const factory BlogPostCreateState({
|
||||||
|
@Default(false) bool isLoading,
|
||||||
|
@Default(false) bool isSuccess,
|
||||||
|
String? errorMessage,
|
||||||
|
String? titleError,
|
||||||
|
String? descriptionError,
|
||||||
|
String? videoUrlError,
|
||||||
|
String? imagesError,
|
||||||
|
String? formError,
|
||||||
|
}) = _BlogPostCreateState;
|
||||||
|
|
||||||
const factory BlogPostCreateState.loaded() = BlogPostCreateStateLoaded;
|
bool get hasValidationErrors =>
|
||||||
|
titleError != null ||
|
||||||
const factory BlogPostCreateState.error({
|
descriptionError != null ||
|
||||||
required String errorMessage,
|
videoUrlError != null ||
|
||||||
}) = BlogPostCreateStateError;
|
imagesError != null ||
|
||||||
|
formError != null;
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,79 +1,122 @@
|
|||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:dio/dio.dart';
|
import 'package:dio/dio.dart';
|
||||||
import 'package:eatvio_app/core/app_config.dart';
|
|
||||||
import 'package:eatvio_app/core/locator_service.dart';
|
|
||||||
import 'package:http/http.dart' as http;
|
|
||||||
import 'package:eatvio_app/core/image_compressing/image_compress_utility.dart';
|
import 'package:eatvio_app/core/image_compressing/image_compress_utility.dart';
|
||||||
import 'package:eatvio_app/features/blog_post_create/services/blog_post_service.dart';
|
import 'package:eatvio_app/features/blog_post_create/services/blog_post_service.dart';
|
||||||
import 'package:flutter_image_compress/flutter_image_compress.dart';
|
import 'package:flutter_image_compress/flutter_image_compress.dart';
|
||||||
import 'package:path_provider/path_provider.dart';
|
|
||||||
|
|
||||||
class BlogPostRepository {
|
class BlogPostRepository {
|
||||||
|
static const maxGalleryImages = 10;
|
||||||
|
|
||||||
final BlogPostService blogPostService;
|
final BlogPostService blogPostService;
|
||||||
|
|
||||||
BlogPostRepository({
|
BlogPostRepository({required this.blogPostService});
|
||||||
required this.blogPostService,
|
|
||||||
});
|
|
||||||
|
|
||||||
Future<void> createBlogPost(
|
Future<void> createBlogPost({
|
||||||
String? title,
|
String? title,
|
||||||
String? videoUrl,
|
String? videoUrl,
|
||||||
String? description,
|
String? description,
|
||||||
XFile? image,
|
List<XFile> images = const [],
|
||||||
) async {
|
}) async {
|
||||||
final formData = FormData.fromMap({
|
final formData = await _buildFormData(
|
||||||
if (title != null && title.isNotEmpty) 'title': title,
|
title: title,
|
||||||
if (description != null && description.isNotEmpty) 'description_md': description,
|
videoUrl: videoUrl,
|
||||||
if (videoUrl != null && videoUrl.isNotEmpty) 'video_url': videoUrl,
|
description: description,
|
||||||
if (image != null) 'image': await MultipartFile.fromFile((await ImageCompressUtility.compressFile(File(image.path))).path, filename: image.name),
|
newImages: images,
|
||||||
});
|
|
||||||
|
|
||||||
return blogPostService.createBlogPost(
|
|
||||||
formData: formData,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
return blogPostService.createBlogPost(formData: formData);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<XFile?> downloadImage(String imageUrl) async {
|
Future<void> updateBlogPost({
|
||||||
final response = await http.get(Uri.parse('${sl<AppConfig>().baseUrl}/storage/$imageUrl'));
|
required int id,
|
||||||
|
String? title,
|
||||||
if (response.statusCode == 200) {
|
String? videoUrl,
|
||||||
final directory = await getTemporaryDirectory();
|
String? description,
|
||||||
final filePath = '${directory.path}/image.jpg';
|
List<int> existingMediaIds = const [],
|
||||||
final file = File(filePath);
|
List<XFile> newImages = const [],
|
||||||
await file.writeAsBytes(response.bodyBytes);
|
List<int> mediaIdsToDelete = const [],
|
||||||
|
}) async {
|
||||||
return XFile(file.path);
|
if (mediaIdsToDelete.isNotEmpty) {
|
||||||
|
await Future.wait(
|
||||||
|
mediaIdsToDelete.map(
|
||||||
|
(mediaId) =>
|
||||||
|
blogPostService.deleteBlogPostMedia(postId: id, mediaId: mediaId),
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return null;
|
|
||||||
|
final formData = await _buildFormData(
|
||||||
|
title: title ?? '',
|
||||||
|
videoUrl: videoUrl ?? '',
|
||||||
|
description: description ?? '',
|
||||||
|
existingMediaIds: existingMediaIds,
|
||||||
|
newImages: newImages,
|
||||||
|
includeExistingMediaIds: true,
|
||||||
|
includeEmptyTextFields: true,
|
||||||
|
);
|
||||||
|
|
||||||
|
return blogPostService.updateBlogPost(id: id, formData: formData);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> updateBlogPost(
|
Future<void> deleteBlogPostMedia({
|
||||||
int id,
|
required int postId,
|
||||||
String? title,
|
required int mediaId,
|
||||||
String? videoUrl,
|
}) async {
|
||||||
String? description,
|
await blogPostService.deleteBlogPostMedia(postId: postId, mediaId: mediaId);
|
||||||
XFile? image,
|
|
||||||
String? oldImageUrl,
|
|
||||||
) async {
|
|
||||||
final formData = FormData.fromMap({
|
|
||||||
'title': title,
|
|
||||||
'description_md': description,
|
|
||||||
'video_url': videoUrl,
|
|
||||||
'image': image != null
|
|
||||||
? await MultipartFile.fromFile((await ImageCompressUtility.compressFile(File(image.path))).path, filename: image.name)
|
|
||||||
: oldImageUrl != '' && oldImageUrl != null
|
|
||||||
? await MultipartFile.fromFile((await downloadImage(oldImageUrl))?.path ?? '', filename: oldImageUrl.split('/').last)
|
|
||||||
: null,
|
|
||||||
});
|
|
||||||
|
|
||||||
return blogPostService.updateBlogPost(
|
|
||||||
id: id,
|
|
||||||
formData: formData,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> deleteBlogPost(int id) async {
|
Future<void> deleteBlogPost(int id) async {
|
||||||
await blogPostService.deleteBlogPost(id: id);
|
await blogPostService.deleteBlogPost(id: id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<FormData> _buildFormData({
|
||||||
|
String? title,
|
||||||
|
String? videoUrl,
|
||||||
|
String? description,
|
||||||
|
List<int> existingMediaIds = const [],
|
||||||
|
List<XFile> newImages = const [],
|
||||||
|
bool includeExistingMediaIds = false,
|
||||||
|
bool includeEmptyTextFields = false,
|
||||||
|
}) async {
|
||||||
|
final formData = FormData();
|
||||||
|
|
||||||
|
if (includeEmptyTextFields) {
|
||||||
|
formData.fields.add(MapEntry('title', title ?? ''));
|
||||||
|
formData.fields.add(MapEntry('description_md', description ?? ''));
|
||||||
|
formData.fields.add(MapEntry('video_url', videoUrl ?? ''));
|
||||||
|
} else {
|
||||||
|
if (title != null && title.isNotEmpty) {
|
||||||
|
formData.fields.add(MapEntry('title', title));
|
||||||
|
}
|
||||||
|
if (description != null && description.isNotEmpty) {
|
||||||
|
formData.fields.add(MapEntry('description_md', description));
|
||||||
|
}
|
||||||
|
if (videoUrl != null && videoUrl.isNotEmpty) {
|
||||||
|
formData.fields.add(MapEntry('video_url', videoUrl));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (includeExistingMediaIds) {
|
||||||
|
for (final mediaId in existingMediaIds) {
|
||||||
|
formData.fields.add(
|
||||||
|
MapEntry('existing_media_ids[]', mediaId.toString()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (final image in newImages) {
|
||||||
|
final compressed = await ImageCompressUtility.compressFile(
|
||||||
|
File(image.path),
|
||||||
|
);
|
||||||
|
formData.files.add(
|
||||||
|
MapEntry(
|
||||||
|
'images[]',
|
||||||
|
await MultipartFile.fromFile(compressed.path, filename: image.name),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return formData;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,21 +7,23 @@ part 'blog_post_service.g.dart';
|
|||||||
abstract class BlogPostService {
|
abstract class BlogPostService {
|
||||||
factory BlogPostService(Dio dio, {String baseUrl}) = _BlogPostService;
|
factory BlogPostService(Dio dio, {String baseUrl}) = _BlogPostService;
|
||||||
|
|
||||||
@POST('/api/blogPosts/create/published')
|
@POST('/api/v2/blogPosts/create/published')
|
||||||
@MultiPart()
|
@MultiPart()
|
||||||
Future<void> createBlogPost({
|
Future<void> createBlogPost({@Body() required FormData formData});
|
||||||
@Body() required FormData formData,
|
|
||||||
});
|
|
||||||
|
|
||||||
@POST('/api/blogPosts/update/published/{id}')
|
@PUT('/api/v2/blogPosts/update/published/{id}')
|
||||||
@MultiPart()
|
@MultiPart()
|
||||||
Future<void> updateBlogPost({
|
Future<void> updateBlogPost({
|
||||||
@Path('id') required int id,
|
@Path('id') required int id,
|
||||||
@Body() required FormData formData,
|
@Body() required FormData formData,
|
||||||
});
|
});
|
||||||
|
|
||||||
@DELETE('/api/blogPosts/delete/{id}')
|
@DELETE('/api/v2/blogPosts/{postId}/media/{mediaId}')
|
||||||
Future<void> deleteBlogPost({
|
Future<void> deleteBlogPostMedia({
|
||||||
@Path('id') required int id,
|
@Path('postId') required int postId,
|
||||||
|
@Path('mediaId') required int mediaId,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@DELETE('/api/blogPosts/delete/{id}')
|
||||||
|
Future<void> deleteBlogPost({@Path('id') required int id});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,158 @@
|
|||||||
|
/// Client-side validation aligned with backend BlogPost V2 create/update rules.
|
||||||
|
abstract final class BlogPostFormValidator {
|
||||||
|
static const minTextLength = 3;
|
||||||
|
static const maxVideoUrlLength = 2048;
|
||||||
|
static const maxGalleryImages = 10;
|
||||||
|
static const allowedImageExtensions = {'jpg', 'jpeg', 'png'};
|
||||||
|
|
||||||
|
static const titleTooShort = 'Заголовок должен содержать минимум 3 символа';
|
||||||
|
static const descriptionTooShort =
|
||||||
|
'Описание должно содержать минимум 3 символа';
|
||||||
|
static const contentRequired =
|
||||||
|
'Добавьте заголовок, описание, изображение или ссылку на видео';
|
||||||
|
static const galleryTooLarge = 'Можно загрузить не более 10 изображений';
|
||||||
|
static const imageTypeInvalid = 'Допустимы только изображения JPG или PNG';
|
||||||
|
static const videoUrlTooLong = 'Ссылка на видео слишком длинная';
|
||||||
|
static const videoUrlInvalid = 'Укажите корректную ссылку на видео';
|
||||||
|
static const videoUrlUnsupportedHost =
|
||||||
|
'Поддерживаются только Rutube, VK и OK';
|
||||||
|
static const videoUrlRutubeFormat = 'Неверный формат ссылки Rutube';
|
||||||
|
static const videoUrlVkFormat = 'Неверный формат ссылки VK';
|
||||||
|
static const videoUrlOkFormat = 'Неверный формат ссылки OK';
|
||||||
|
|
||||||
|
/// Empty is allowed; non-empty must be at least [minTextLength] chars (after trim).
|
||||||
|
static String? title(String? value) {
|
||||||
|
final text = value?.trim() ?? '';
|
||||||
|
if (text.isEmpty) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (text.length < minTextLength) {
|
||||||
|
return titleTooShort;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Empty is allowed; non-empty must be at least [minTextLength] chars (after trim).
|
||||||
|
static String? description(String? value) {
|
||||||
|
final text = value?.trim() ?? '';
|
||||||
|
if (text.isEmpty) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (text.length < minTextLength) {
|
||||||
|
return descriptionTooShort;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Empty is allowed; non-empty must match allowed video hosts/paths.
|
||||||
|
static String? videoUrl(String? value) {
|
||||||
|
final url = value?.trim() ?? '';
|
||||||
|
if (url.isEmpty) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (url.length > maxVideoUrlLength) {
|
||||||
|
return videoUrlTooLong;
|
||||||
|
}
|
||||||
|
|
||||||
|
final uri = Uri.tryParse(url);
|
||||||
|
if (uri == null || !uri.hasScheme || uri.host.isEmpty) {
|
||||||
|
return videoUrlInvalid;
|
||||||
|
}
|
||||||
|
if (uri.scheme != 'http' && uri.scheme != 'https') {
|
||||||
|
return videoUrlInvalid;
|
||||||
|
}
|
||||||
|
|
||||||
|
final host = uri.host.toLowerCase();
|
||||||
|
if (!_hostMatchesRootAllowlist(host, const [
|
||||||
|
'vk.com',
|
||||||
|
'vkvideo.ru',
|
||||||
|
'ok.ru',
|
||||||
|
'rutube.ru',
|
||||||
|
])) {
|
||||||
|
return videoUrlUnsupportedHost;
|
||||||
|
}
|
||||||
|
|
||||||
|
final path = uri.path;
|
||||||
|
if (_hostMatchesRootAllowlist(host, const ['rutube.ru'])) {
|
||||||
|
if (!_rutubePath.hasMatch(path)) {
|
||||||
|
return videoUrlRutubeFormat;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (_hostMatchesRootAllowlist(host, const ['vk.com', 'vkvideo.ru'])) {
|
||||||
|
if (!_vkPath.hasMatch(path)) {
|
||||||
|
return videoUrlVkFormat;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (_hostMatchesRootAllowlist(host, const ['ok.ru'])) {
|
||||||
|
if (!_okPath.hasMatch(path)) {
|
||||||
|
return videoUrlOkFormat;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// At least one of title / description / images / video must be meaningfully filled.
|
||||||
|
static String? contentRequiredMessage({
|
||||||
|
required String title,
|
||||||
|
required String description,
|
||||||
|
required String videoUrl,
|
||||||
|
required int imageCount,
|
||||||
|
}) {
|
||||||
|
final hasTitle = title.trim().length >= minTextLength;
|
||||||
|
final hasDescription = description.trim().length >= minTextLength;
|
||||||
|
final trimmedVideo = videoUrl.trim();
|
||||||
|
final hasVideo =
|
||||||
|
trimmedVideo.isNotEmpty &&
|
||||||
|
BlogPostFormValidator.videoUrl(trimmedVideo) == null;
|
||||||
|
final hasImages = imageCount > 0;
|
||||||
|
|
||||||
|
if (hasTitle || hasDescription || hasVideo || hasImages) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return contentRequired;
|
||||||
|
}
|
||||||
|
|
||||||
|
static String? galleryCount(int count) {
|
||||||
|
if (count > maxGalleryImages) {
|
||||||
|
return galleryTooLarge;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns error if any path has a non-allowed extension. Empty path is ignored.
|
||||||
|
static String? imagePaths(Iterable<String> paths) {
|
||||||
|
for (final path in paths) {
|
||||||
|
final name = path.split(RegExp(r'[\\/]')).last;
|
||||||
|
final dot = name.lastIndexOf('.');
|
||||||
|
if (dot < 0 || dot == name.length - 1) {
|
||||||
|
return imageTypeInvalid;
|
||||||
|
}
|
||||||
|
final ext = name.substring(dot + 1).toLowerCase();
|
||||||
|
if (!allowedImageExtensions.contains(ext)) {
|
||||||
|
return imageTypeInvalid;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
static final _rutubePath = RegExp(
|
||||||
|
r'^/(video|shorts)/[a-z0-9]+/?$',
|
||||||
|
caseSensitive: false,
|
||||||
|
);
|
||||||
|
static final _vkPath = RegExp(r'^/video-?\d+_\d+/?$', caseSensitive: false);
|
||||||
|
static final _okPath = RegExp(r'^/video/\d+/?$');
|
||||||
|
|
||||||
|
static bool _hostMatchesRootAllowlist(
|
||||||
|
String host,
|
||||||
|
List<String> allowedRootHosts,
|
||||||
|
) {
|
||||||
|
for (final allowed in allowedRootHosts) {
|
||||||
|
if (host == allowed || host.endsWith('.$allowed')) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -54,16 +54,16 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
|
|||||||
if (currentPage < lastPage && !isLoading) {
|
if (currentPage < lastPage && !isLoading) {
|
||||||
isLoading = true;
|
isLoading = true;
|
||||||
|
|
||||||
emit((state as ChatRoomStateLoaded).copyWith(isLoading: isLoading));
|
_emitLoaded((current) => current.copyWith(isLoading: isLoading));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final chatMessages = await chatRepository.getChatMessages(uuid, currentPage + 1);
|
final chatMessages = await chatRepository.getChatMessages(uuid, currentPage + 1);
|
||||||
|
|
||||||
isLoading = false;
|
isLoading = false;
|
||||||
emit(
|
_emitLoaded(
|
||||||
(state as ChatRoomStateLoaded).copyWith(
|
(current) => current.copyWith(
|
||||||
isLoading: isLoading,
|
isLoading: isLoading,
|
||||||
chatMessages: [...(state as ChatRoomStateLoaded).chatMessages, ...chatMessages.messagesList],
|
chatMessages: [...current.chatMessages, ...chatMessages.messagesList],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -86,11 +86,8 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _setCard({required List<CardModel> cards}) {
|
void _setCard({required List<CardModel> cards}) {
|
||||||
final currentState = state;
|
if (cards.where((card) => card.isDefault).isNotEmpty) {
|
||||||
if (currentState is ChatRoomStateLoaded) {
|
_emitLoaded((current) => current.copyWith(hasCard: true));
|
||||||
if (cards.where((card) => card.isDefault).isNotEmpty) {
|
|
||||||
emit(currentState.copyWith(hasCard: true));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -117,6 +114,9 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
|
|||||||
throw ArgumentError('initChat requires uuid or orderId');
|
throw ArgumentError('initChat requires uuid or orderId');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
currentPage = 1;
|
||||||
|
isLoading = false;
|
||||||
|
|
||||||
final chatRoom = await chatRepository.getChatRoom(resolvedUuid);
|
final chatRoom = await chatRepository.getChatRoom(resolvedUuid);
|
||||||
ChosenPlanModel? chosenPlan;
|
ChosenPlanModel? chosenPlan;
|
||||||
final planId = resolvedChosenPlanId ?? chatRoom.chosenPlanId;
|
final planId = resolvedChosenPlanId ?? chatRoom.chosenPlanId;
|
||||||
@@ -135,6 +135,7 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
|
|||||||
hasCard: false,
|
hasCard: false,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
await _subscribeToPresenceChannel(chatId: chatRoom.id);
|
||||||
|
|
||||||
if (chosenPlan?.consultantId == chatRoom.users.sender.id) {
|
if (chosenPlan?.consultantId == chatRoom.users.sender.id) {
|
||||||
await checkCards();
|
await checkCards();
|
||||||
@@ -154,13 +155,15 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
|
|||||||
Future<void> updateChosenPlanState() async {
|
Future<void> updateChosenPlanState() async {
|
||||||
try {
|
try {
|
||||||
final currentState = state;
|
final currentState = state;
|
||||||
if (currentState is ChatRoomStateLoaded) {
|
if (currentState is! ChatRoomStateLoaded) {
|
||||||
final chosenPlanId = currentState.chatRoom.chosenPlanId;
|
return;
|
||||||
if (chosenPlanId != null) {
|
|
||||||
final chosenPlan = await chatRepository.getChosenPlan(id: chosenPlanId);
|
|
||||||
emit(currentState.copyWith.chatRoom(chosenPlan: chosenPlan));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
final chosenPlanId = currentState.chatRoom.chosenPlanId;
|
||||||
|
if (chosenPlanId == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final chosenPlan = await chatRepository.getChosenPlan(id: chosenPlanId);
|
||||||
|
_emitLoaded((latest) => latest.copyWith.chatRoom(chosenPlan: chosenPlan));
|
||||||
} catch (e, st) {
|
} catch (e, st) {
|
||||||
await reportFrom(e, st, this);
|
await reportFrom(e, st, this);
|
||||||
}
|
}
|
||||||
@@ -193,38 +196,92 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool _emitLoaded(ChatRoomStateLoaded Function(ChatRoomStateLoaded current) update) {
|
||||||
|
if (isClosed) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
final currentState = state;
|
||||||
|
if (currentState is! ChatRoomStateLoaded) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
emit(update(currentState));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _subscribeToChatChannel({required String uuid, required String chatId}) async {
|
Future<void> _subscribeToChatChannel({required String uuid, required String chatId}) async {
|
||||||
final client = wsManager.client;
|
final client = wsManager.client;
|
||||||
if (client == null) return;
|
if (client == null) return;
|
||||||
_activeChatUuid = uuid;
|
_activeChatUuid = uuid;
|
||||||
|
await messagesSubscription?.cancel();
|
||||||
|
await typingSubscription?.cancel();
|
||||||
|
messagesSubscription = null;
|
||||||
|
typingSubscription = null;
|
||||||
await wsManager.initPrivateDirectChatChannel(chatId);
|
await wsManager.initPrivateDirectChatChannel(chatId);
|
||||||
wsManager.subscribeDirectChatChannel();
|
wsManager.subscribeDirectChatChannel();
|
||||||
await _subscribeToPresenceChannel(chatId: chatId);
|
messagesSubscription = wsManager.directChatStream?.listen(
|
||||||
messagesSubscription = wsManager.directChatStream?.listen((event) {
|
_onDirectChatEvent,
|
||||||
AudioPlayer().play((AssetSource('audio/recive_message.wav')));
|
onError: (Object e, StackTrace st) {
|
||||||
final message = ChatMessageModel.fromJson(jsonDecode(event.data));
|
unawaited(reportFrom(e, st, this));
|
||||||
final currentState = state as ChatRoomStateLoaded;
|
},
|
||||||
|
);
|
||||||
// Сообщения от текущего пользователя не приходят через WebSocket,
|
|
||||||
// они обрабатываются через ответ API в sendMessage
|
|
||||||
// Здесь обрабатываем только входящие сообщения от других пользователей
|
|
||||||
List<ChatMessageModel> messagesList = [message, ...currentState.chatMessages];
|
|
||||||
|
|
||||||
chatRepository.emitNewMessageToChats(message);
|
|
||||||
emit(currentState.copyWith(chatMessages: messagesList, isLoading: isLoading));
|
|
||||||
EasyDebounce.debounce('searchChatRoom', const Duration(milliseconds: 300), () => sl<ChatsCubit>().getChatRooms());
|
|
||||||
});
|
|
||||||
|
|
||||||
typingSubscription = wsManager.clientTypingStream?.listen((event) {
|
typingSubscription = wsManager.clientTypingStream?.listen((event) {
|
||||||
emit((state as ChatRoomStateLoaded).copyWith(isTyping: true, isLoading: isLoading));
|
_emitLoaded((current) => current.copyWith(isTyping: true, isLoading: isLoading));
|
||||||
EasyDebounce.debounce(
|
EasyDebounce.debounce(
|
||||||
'client-typing',
|
'client-typing',
|
||||||
const Duration(seconds: 3),
|
const Duration(seconds: 3),
|
||||||
() => emit((state as ChatRoomStateLoaded).copyWith(isTyping: false, isLoading: isLoading)),
|
() => _emitLoaded((current) => current.copyWith(isTyping: false, isLoading: isLoading)),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _onDirectChatEvent(ChannelReadEvent event) {
|
||||||
|
try {
|
||||||
|
if (addIncomingMessage(ChatMessageModel.fromJson(jsonDecode(event.data) as Map<String, dynamic>))) {
|
||||||
|
unawaited(_playIncomingSound());
|
||||||
|
}
|
||||||
|
} catch (e, st) {
|
||||||
|
unawaited(reportFrom(e, st, this));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _playIncomingSound() async {
|
||||||
|
try {
|
||||||
|
await AudioPlayer().play(AssetSource('audio/recive_message.wav'));
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Добавляет входящее сообщение в текущий список, не затирая более новый state.
|
||||||
|
bool addIncomingMessage(ChatMessageModel message) {
|
||||||
|
final currentUserId = wsManager.userId;
|
||||||
|
if (currentUserId != null && message.authorId == currentUserId) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
final currentState = state;
|
||||||
|
if (currentState is! ChatRoomStateLoaded) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (currentState.chatMessages.any((existing) => existing.id == message.id && message.id != 0)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
final added = _emitLoaded((current) {
|
||||||
|
if (current.chatMessages.any((existing) => existing.id == message.id && message.id != 0)) {
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
return current.copyWith(
|
||||||
|
chatMessages: [message, ...current.chatMessages],
|
||||||
|
isLoading: isLoading,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
if (!added) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
chatRepository.emitNewMessageToChats(message);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _subscribeToPresenceChannel({required String chatId}) async {
|
Future<void> _subscribeToPresenceChannel({required String chatId}) async {
|
||||||
final currentState = state;
|
final currentState = state;
|
||||||
if (currentState is! ChatRoomStateLoaded) return;
|
if (currentState is! ChatRoomStateLoaded) return;
|
||||||
@@ -341,59 +398,38 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
|
|||||||
try {
|
try {
|
||||||
final messageUuid = uuid.v4();
|
final messageUuid = uuid.v4();
|
||||||
final compressedFiles = files != null ? await ImageCompressUtility.compressFilesList(List<File>.from(files)) : null;
|
final compressedFiles = files != null ? await ImageCompressUtility.compressFilesList(List<File>.from(files)) : null;
|
||||||
AudioPlayer().play((AssetSource('audio/send_message.wav')));
|
try {
|
||||||
|
unawaited(AudioPlayer().play(AssetSource('audio/send_message.wav')));
|
||||||
|
} catch (_) {}
|
||||||
|
|
||||||
// Создаем временное сообщение с состоянием loading
|
_emitLoaded(
|
||||||
emit(
|
(current) => current.copyWith(
|
||||||
(state as ChatRoomStateLoaded).copyWith(
|
|
||||||
chatMessages: [
|
chatMessages: [
|
||||||
ChatMessageModel(
|
ChatMessageModel(
|
||||||
id: 1,
|
id: -messageUuid.hashCode.abs(),
|
||||||
data: data ?? '',
|
data: data ?? '',
|
||||||
mediaData: compressedFiles,
|
mediaData: compressedFiles,
|
||||||
authorId: 1,
|
authorId: wsManager.userId ?? 0,
|
||||||
updatedAt: DateTime.now(),
|
updatedAt: DateTime.now(),
|
||||||
isRead: true,
|
isRead: true,
|
||||||
isCurrentUserAuthor: true,
|
isCurrentUserAuthor: true,
|
||||||
roomId: '',
|
roomId: current.chatRoom.id,
|
||||||
messageState: MessageState.loading,
|
messageState: MessageState.loading,
|
||||||
uuid: messageUuid,
|
uuid: messageUuid,
|
||||||
),
|
),
|
||||||
...(state as ChatRoomStateLoaded).chatMessages,
|
...current.chatMessages,
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Отправляем сообщение и получаем ответ от сервера с правильными данными
|
|
||||||
final serverMessage = await chatRepository.sendMessage(data: data, files: compressedFiles, slug: userSlug);
|
final serverMessage = await chatRepository.sendMessage(data: data, files: compressedFiles, slug: userSlug);
|
||||||
|
_replaceOptimisticMessage(messageUuid: messageUuid, serverMessage: serverMessage);
|
||||||
// Заменяем локальное сообщение данными с сервера (с правильными URL файлов)
|
|
||||||
emit(
|
|
||||||
(state as ChatRoomStateLoaded).copyWith(
|
|
||||||
chatMessages: (state as ChatRoomStateLoaded).chatMessages.map((elem) {
|
|
||||||
if (elem.uuid == messageUuid) {
|
|
||||||
// Сохраняем UUID, состояние и обновляем данные из ответа сервера
|
|
||||||
// Используем значения из локального сообщения для полей, которые могут отсутствовать в ответе
|
|
||||||
return serverMessage.copyWith(
|
|
||||||
uuid: messageUuid,
|
|
||||||
messageState: MessageState.sent,
|
|
||||||
isCurrentUserAuthor: true,
|
|
||||||
isRead: elem.isRead,
|
|
||||||
roomId: elem.roomId ?? serverMessage.roomId,
|
|
||||||
authorId: serverMessage.authorId,
|
|
||||||
updatedAt: serverMessage.updatedAt,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return elem;
|
|
||||||
}).toList(),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
} catch (e, st) {
|
} catch (e, st) {
|
||||||
await reportFrom(e, st, this);
|
await reportFrom(e, st, this);
|
||||||
emit(
|
_emitLoaded(
|
||||||
(state as ChatRoomStateLoaded).copyWith(
|
(current) => current.copyWith(
|
||||||
chatMessages: (state as ChatRoomStateLoaded).chatMessages.map((elem) {
|
chatMessages: current.chatMessages.map((elem) {
|
||||||
if (elem.uuid == messageUuid) {
|
if (elem.uuid == messageUuid) {
|
||||||
return elem.copyWith(messageState: MessageState.error);
|
return elem.copyWith(messageState: MessageState.error);
|
||||||
}
|
}
|
||||||
@@ -411,21 +447,21 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<bool> markMessagesAsRead(String chatId, List<({int id, int index})> messagesIds) async {
|
Future<bool> markMessagesAsRead(String chatId, List<int> messageIds) async {
|
||||||
try {
|
try {
|
||||||
if (state is ChatRoomStateLoaded) {
|
if (state is! ChatRoomStateLoaded) {
|
||||||
final ids = messagesIds.map((e) => e.id).toList();
|
return false;
|
||||||
await chatRepository.markMessagesAsRead(chatId, ids);
|
|
||||||
final currentMessages = [...(state as ChatRoomStateLoaded).chatMessages];
|
|
||||||
for (var elem in messagesIds) {
|
|
||||||
currentMessages[elem.index] = currentMessages[elem.index].copyWith(isRead: true);
|
|
||||||
}
|
|
||||||
if (!isClosed) {
|
|
||||||
emit((state as ChatRoomStateLoaded).copyWith(chatMessages: currentMessages));
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
return false;
|
await chatRepository.markMessagesAsRead(chatId, messageIds);
|
||||||
|
final idSet = messageIds.toSet();
|
||||||
|
_emitLoaded(
|
||||||
|
(current) => current.copyWith(
|
||||||
|
chatMessages: current.chatMessages
|
||||||
|
.map((message) => idSet.contains(message.id) ? message.copyWith(isRead: true) : message)
|
||||||
|
.toList(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return true;
|
||||||
} catch (e, st) {
|
} catch (e, st) {
|
||||||
await reportFrom(e, st, this);
|
await reportFrom(e, st, this);
|
||||||
log(e.toString(), stackTrace: st);
|
log(e.toString(), stackTrace: st);
|
||||||
@@ -433,6 +469,35 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _replaceOptimisticMessage({
|
||||||
|
required String messageUuid,
|
||||||
|
required ChatMessageModel serverMessage,
|
||||||
|
}) {
|
||||||
|
_emitLoaded((current) {
|
||||||
|
final sentMessage = serverMessage.copyWith(
|
||||||
|
uuid: messageUuid,
|
||||||
|
messageState: MessageState.sent,
|
||||||
|
isCurrentUserAuthor: true,
|
||||||
|
);
|
||||||
|
final withoutDuplicate = current.chatMessages.where((message) => message.id != serverMessage.id || message.uuid == messageUuid);
|
||||||
|
final hasOptimistic = withoutDuplicate.any((message) => message.uuid == messageUuid);
|
||||||
|
if (!hasOptimistic) {
|
||||||
|
return current.copyWith(chatMessages: [sentMessage, ...withoutDuplicate]);
|
||||||
|
}
|
||||||
|
return current.copyWith(
|
||||||
|
chatMessages: withoutDuplicate.map((elem) {
|
||||||
|
if (elem.uuid == messageUuid) {
|
||||||
|
return sentMessage.copyWith(
|
||||||
|
isRead: elem.isRead,
|
||||||
|
roomId: elem.roomId?.isNotEmpty == true ? elem.roomId : serverMessage.roomId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return elem;
|
||||||
|
}).toList(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
Future<String?> getPaymentLink() async {
|
Future<String?> getPaymentLink() async {
|
||||||
try {
|
try {
|
||||||
final planId = (state as ChatRoomStateLoaded).chatRoom.chosenPlan?.id;
|
final planId = (state as ChatRoomStateLoaded).chatRoom.chosenPlan?.id;
|
||||||
@@ -477,14 +542,16 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> close() async {
|
Future<void> close() async {
|
||||||
|
EasyDebounce.cancel('client-typing');
|
||||||
|
EasyDebounce.cancel('searchChatRoom');
|
||||||
if (_presenceOnlineListener != null) {
|
if (_presenceOnlineListener != null) {
|
||||||
wsManager.removePresenceDirectChatListener(_presenceOnlineListener!);
|
wsManager.removePresenceDirectChatListener(_presenceOnlineListener!);
|
||||||
_presenceOnlineListener = null;
|
_presenceOnlineListener = null;
|
||||||
}
|
}
|
||||||
wsManager.unsubscribeDirectChatChannel();
|
wsManager.unsubscribeDirectChatChannel();
|
||||||
_timer?.cancel();
|
_timer?.cancel();
|
||||||
messagesSubscription?.cancel();
|
await messagesSubscription?.cancel();
|
||||||
typingSubscription?.cancel();
|
await typingSubscription?.cancel();
|
||||||
super.close();
|
super.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -501,67 +568,46 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
|
|||||||
final message = currentState.chatMessages.firstWhereOrNull((message) => message.uuid == uuid);
|
final message = currentState.chatMessages.firstWhereOrNull((message) => message.uuid == uuid);
|
||||||
if (message != null) {
|
if (message != null) {
|
||||||
try {
|
try {
|
||||||
AudioPlayer().play((AssetSource('audio/send_message.wav')));
|
try {
|
||||||
|
unawaited(AudioPlayer().play(AssetSource('audio/send_message.wav')));
|
||||||
|
} catch (_) {}
|
||||||
|
|
||||||
// Определяем, есть ли файлы для повторной отправки
|
|
||||||
List<File>? filesToResend;
|
List<File>? filesToResend;
|
||||||
if (message.mediaData != null && message.mediaData!.isNotEmpty) {
|
if (message.mediaData != null && message.mediaData!.isNotEmpty) {
|
||||||
// Если mediaData содержит File объекты, используем их
|
|
||||||
// Если это строки (URL), то файлы уже на сервере и повторная отправка не нужна
|
|
||||||
final firstItem = message.mediaData!.first;
|
final firstItem = message.mediaData!.first;
|
||||||
if (firstItem is File) {
|
if (firstItem is File) {
|
||||||
filesToResend = message.mediaData!.cast<File>();
|
filesToResend = message.mediaData!.cast<File>();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
emit(
|
_emitLoaded(
|
||||||
(state as ChatRoomStateLoaded).copyWith(
|
(current) => current.copyWith(
|
||||||
chatMessages: [
|
chatMessages: [
|
||||||
ChatMessageModel(
|
ChatMessageModel(
|
||||||
id: 1,
|
id: -uuid.hashCode.abs(),
|
||||||
data: message.data,
|
data: message.data,
|
||||||
mediaData: message.mediaData,
|
mediaData: message.mediaData,
|
||||||
authorId: 1,
|
authorId: wsManager.userId ?? 0,
|
||||||
updatedAt: DateTime.now(),
|
updatedAt: DateTime.now(),
|
||||||
isRead: true,
|
isRead: true,
|
||||||
isCurrentUserAuthor: true,
|
isCurrentUserAuthor: true,
|
||||||
roomId: '',
|
roomId: current.chatRoom.id,
|
||||||
messageState: MessageState.loading,
|
messageState: MessageState.loading,
|
||||||
uuid: uuid,
|
uuid: uuid,
|
||||||
),
|
),
|
||||||
...(state as ChatRoomStateLoaded).chatMessages.where((message) => message.uuid != uuid),
|
...current.chatMessages.where((item) => item.uuid != uuid),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Отправляем сообщение и получаем ответ от сервера
|
|
||||||
final serverMessage = await chatRepository.sendMessage(data: message.data, files: filesToResend, slug: userSlug);
|
final serverMessage = await chatRepository.sendMessage(data: message.data, files: filesToResend, slug: userSlug);
|
||||||
|
_replaceOptimisticMessage(messageUuid: uuid, serverMessage: serverMessage);
|
||||||
// Заменяем локальное сообщение данными с сервера
|
|
||||||
emit(
|
|
||||||
(state as ChatRoomStateLoaded).copyWith(
|
|
||||||
chatMessages: (state as ChatRoomStateLoaded).chatMessages.map((elem) {
|
|
||||||
if (elem.uuid == uuid) {
|
|
||||||
return serverMessage.copyWith(
|
|
||||||
uuid: uuid,
|
|
||||||
messageState: MessageState.sent,
|
|
||||||
isCurrentUserAuthor: true,
|
|
||||||
isRead: elem.isRead,
|
|
||||||
roomId: elem.roomId ?? serverMessage.roomId,
|
|
||||||
authorId: serverMessage.authorId,
|
|
||||||
updatedAt: serverMessage.updatedAt,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return elem;
|
|
||||||
}).toList(),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
} catch (e, st) {
|
} catch (e, st) {
|
||||||
await reportFrom(e, st, this);
|
await reportFrom(e, st, this);
|
||||||
emit(
|
_emitLoaded(
|
||||||
(state as ChatRoomStateLoaded).copyWith(
|
(current) => current.copyWith(
|
||||||
chatMessages: (state as ChatRoomStateLoaded).chatMessages.map((elem) {
|
chatMessages: current.chatMessages.map((elem) {
|
||||||
if (elem.uuid == uuid) {
|
if (elem.uuid == uuid) {
|
||||||
return elem.copyWith(messageState: MessageState.error);
|
return elem.copyWith(messageState: MessageState.error);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -141,8 +141,8 @@ class ChatsCubit extends Cubit<ChatsState> {
|
|||||||
|
|
||||||
Future<void> _handleNewMessageViaSocket(dynamic data) async {
|
Future<void> _handleNewMessageViaSocket(dynamic data) async {
|
||||||
try {
|
try {
|
||||||
final json = jsonDecode(data);
|
final json = jsonDecode(data) as Map<String, dynamic>;
|
||||||
final message = ChatMessageModel.fromJson(json['message']);
|
final message = ChatMessageModel.fromJson(json);
|
||||||
|
|
||||||
final currentChatRooms = [...state.chatRooms];
|
final currentChatRooms = [...state.chatRooms];
|
||||||
|
|
||||||
|
|||||||
@@ -56,8 +56,7 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
final scrollController = ScrollController();
|
final scrollController = ScrollController();
|
||||||
final TextEditingController textEditingController = TextEditingController();
|
final TextEditingController textEditingController = TextEditingController();
|
||||||
final ChatRoomCubit chatRoomCubit = sl<ChatRoomCubit>();
|
final ChatRoomCubit chatRoomCubit = sl<ChatRoomCubit>();
|
||||||
final List<({int id, int index})> unreadMessagesIds = [];
|
final List<int> unreadMessageIds = [];
|
||||||
final List<({int id, int index})> unreadMessagesIdsCopy = [];
|
|
||||||
final showableChatBadgeStates = ChosenPlanStateEnum.getShowableChatBadgeStates();
|
final showableChatBadgeStates = ChosenPlanStateEnum.getShowableChatBadgeStates();
|
||||||
late AppLifecycleListener _listener;
|
late AppLifecycleListener _listener;
|
||||||
List<File> pickedFiles = [];
|
List<File> pickedFiles = [];
|
||||||
@@ -116,6 +115,7 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
EasyDebounce.cancel('mark_as_read');
|
||||||
scrollController.dispose();
|
scrollController.dispose();
|
||||||
_listener.dispose();
|
_listener.dispose();
|
||||||
chatRoomCubit.close();
|
chatRoomCubit.close();
|
||||||
@@ -337,21 +337,15 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
isCurrentUser: isCurrentUserAuthor,
|
isCurrentUser: isCurrentUserAuthor,
|
||||||
),
|
),
|
||||||
VisibilityDetector(
|
VisibilityDetector(
|
||||||
key: Key(message.id.toString()),
|
key: ValueKey('msg-${message.uuid ?? message.id}'),
|
||||||
onVisibilityChanged: (VisibilityInfo info) {
|
onVisibilityChanged: (VisibilityInfo info) {
|
||||||
if (!message.isRead && !isCurrentUserAuthor && info.visibleFraction > 0) {
|
_queueMarkAsRead(
|
||||||
unreadMessagesIdsCopy.add((id: message.id, index: index));
|
context: context,
|
||||||
EasyDebounce.debounce('mark_as_read', const Duration(milliseconds: 500), () async {
|
chatId: state.chatRoom.id,
|
||||||
final result = await context.read<ChatRoomCubit>().markMessagesAsRead(state.chatRoom.id, unreadMessagesIdsCopy);
|
message: message,
|
||||||
if (!result) return;
|
isCurrentUserAuthor: isCurrentUserAuthor,
|
||||||
if (!context.mounted) return;
|
visibleFraction: info.visibleFraction,
|
||||||
context.read<ChatsCubit>().reduceUnreadMessagesCount(
|
);
|
||||||
chatId: state.chatRoom.id,
|
|
||||||
count: unreadMessagesIdsCopy.length,
|
|
||||||
);
|
|
||||||
unreadMessagesIdsCopy.clear();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
child: _buildMessageBubble(
|
child: _buildMessageBubble(
|
||||||
context: context,
|
context: context,
|
||||||
@@ -391,18 +385,15 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
isCurrentUser: isCurrentUserAuthor,
|
isCurrentUser: isCurrentUserAuthor,
|
||||||
),
|
),
|
||||||
VisibilityDetector(
|
VisibilityDetector(
|
||||||
key: Key(message.id.toString()),
|
key: ValueKey('msg-${message.uuid ?? message.id}'),
|
||||||
onVisibilityChanged: (VisibilityInfo info) {
|
onVisibilityChanged: (VisibilityInfo info) {
|
||||||
if (!message.isRead && !isCurrentUserAuthor && info.visibleFraction > 0) {
|
_queueMarkAsRead(
|
||||||
unreadMessagesIds.add((id: message.id, index: index));
|
context: context,
|
||||||
EasyDebounce.debounce('mark_as_read', const Duration(milliseconds: 500), () async {
|
chatId: state.chatRoom.id,
|
||||||
final result = await context.read<ChatRoomCubit>().markMessagesAsRead(state.chatRoom.id, unreadMessagesIds);
|
message: message,
|
||||||
if (!result) return;
|
isCurrentUserAuthor: isCurrentUserAuthor,
|
||||||
if (!context.mounted) return;
|
visibleFraction: info.visibleFraction,
|
||||||
context.read<ChatsCubit>().reduceUnreadMessagesCount(chatId: state.chatRoom.id, count: unreadMessagesIds.length);
|
);
|
||||||
unreadMessagesIds.clear();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
child: _buildMessageBubble(
|
child: _buildMessageBubble(
|
||||||
context: context,
|
context: context,
|
||||||
@@ -793,6 +784,33 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _queueMarkAsRead({
|
||||||
|
required BuildContext context,
|
||||||
|
required String chatId,
|
||||||
|
required ChatMessageModel message,
|
||||||
|
required bool isCurrentUserAuthor,
|
||||||
|
required double visibleFraction,
|
||||||
|
}) {
|
||||||
|
if (message.isRead || isCurrentUserAuthor || visibleFraction <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!unreadMessageIds.contains(message.id)) {
|
||||||
|
unreadMessageIds.add(message.id);
|
||||||
|
}
|
||||||
|
EasyDebounce.debounce('mark_as_read', const Duration(milliseconds: 500), () async {
|
||||||
|
final ids = List<int>.from(unreadMessageIds);
|
||||||
|
if (ids.isEmpty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
unreadMessageIds.clear();
|
||||||
|
final result = await context.read<ChatRoomCubit>().markMessagesAsRead(chatId, ids);
|
||||||
|
if (!result || !context.mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
context.read<ChatsCubit>().reduceUnreadMessagesCount(chatId: chatId, count: ids.length);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
Widget _buildMessageBubble({
|
Widget _buildMessageBubble({
|
||||||
required BuildContext context,
|
required BuildContext context,
|
||||||
required ChatRoomStateLoaded state,
|
required ChatRoomStateLoaded state,
|
||||||
|
|||||||
@@ -14,11 +14,15 @@ class PhotoViewScreen extends StatefulWidget {
|
|||||||
final int index;
|
final int index;
|
||||||
final bool isLocal;
|
final bool isLocal;
|
||||||
|
|
||||||
|
/// When set, used instead of the image URL so source/destination tags stay unique.
|
||||||
|
final Object? heroTag;
|
||||||
|
|
||||||
const PhotoViewScreen({
|
const PhotoViewScreen({
|
||||||
super.key,
|
super.key,
|
||||||
required this.imageUrls,
|
required this.imageUrls,
|
||||||
required this.index,
|
required this.index,
|
||||||
this.isLocal = false,
|
this.isLocal = false,
|
||||||
|
this.heroTag,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -36,6 +40,23 @@ class _PhotoViewScreenState extends State<PhotoViewScreen> {
|
|||||||
super.initState();
|
super.initState();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
PhotoViewHeroAttributes? _heroAttributesFor(int index) {
|
||||||
|
if (widget.heroTag != null) {
|
||||||
|
if (index != widget.index) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return PhotoViewHeroAttributes(
|
||||||
|
tag: widget.heroTag!,
|
||||||
|
transitionOnUserGestures: true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return PhotoViewHeroAttributes(
|
||||||
|
tag: widget.imageUrls[index],
|
||||||
|
transitionOnUserGestures: true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return DismissiblePage(
|
return DismissiblePage(
|
||||||
@@ -44,23 +65,19 @@ class _PhotoViewScreenState extends State<PhotoViewScreen> {
|
|||||||
direction: DismissiblePageDismissDirection.vertical,
|
direction: DismissiblePageDismissDirection.vertical,
|
||||||
isFullScreen: true,
|
isFullScreen: true,
|
||||||
child: PhotoViewGallery.builder(
|
child: PhotoViewGallery.builder(
|
||||||
backgroundDecoration: const BoxDecoration(
|
backgroundDecoration: const BoxDecoration(color: Colors.transparent),
|
||||||
color: Colors.transparent,
|
|
||||||
),
|
|
||||||
scrollPhysics: const BouncingScrollPhysics(),
|
scrollPhysics: const BouncingScrollPhysics(),
|
||||||
builder: (BuildContext context, int index) {
|
builder: (BuildContext context, int index) {
|
||||||
final imageProvider = widget.isLocal
|
final imageProvider = widget.isLocal
|
||||||
? FileImage(widget.imageUrls[index]) as ImageProvider<Object>
|
? FileImage(widget.imageUrls[index]) as ImageProvider<Object>
|
||||||
: CachedNetworkImageProvider(widget.imageUrls[index]) as ImageProvider<Object>;
|
: CachedNetworkImageProvider(widget.imageUrls[index])
|
||||||
|
as ImageProvider<Object>;
|
||||||
|
|
||||||
return PhotoViewGalleryPageOptions(
|
return PhotoViewGalleryPageOptions(
|
||||||
imageProvider: imageProvider,
|
imageProvider: imageProvider,
|
||||||
initialScale: PhotoViewComputedScale.contained,
|
initialScale: PhotoViewComputedScale.contained,
|
||||||
minScale: PhotoViewComputedScale.contained,
|
minScale: PhotoViewComputedScale.contained,
|
||||||
heroAttributes: PhotoViewHeroAttributes(
|
heroAttributes: _heroAttributesFor(index),
|
||||||
tag: widget.imageUrls[index],
|
|
||||||
transitionOnUserGestures: true,
|
|
||||||
),
|
|
||||||
errorBuilder: (context, object, stackTrace) {
|
errorBuilder: (context, object, stackTrace) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: Colors.black,
|
backgroundColor: Colors.black,
|
||||||
@@ -76,8 +93,12 @@ class _PhotoViewScreenState extends State<PhotoViewScreen> {
|
|||||||
),
|
),
|
||||||
contentPadding: const EdgeInsets.all(16),
|
contentPadding: const EdgeInsets.all(16),
|
||||||
onTap: () async {
|
onTap: () async {
|
||||||
if (!await launchUrl(Uri.parse(widget.imageUrls[index]))) {
|
if (!await launchUrl(
|
||||||
throw Exception('Could not launch ${widget.imageUrls[index]}');
|
Uri.parse(widget.imageUrls[index]),
|
||||||
|
)) {
|
||||||
|
throw Exception(
|
||||||
|
'Could not launch ${widget.imageUrls[index]}',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
leading: const Icon(
|
leading: const Icon(
|
||||||
@@ -89,7 +110,10 @@ class _PhotoViewScreenState extends State<PhotoViewScreen> {
|
|||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
),
|
),
|
||||||
title: Text(
|
title: Text(
|
||||||
widget.imageUrls[index].replaceAll('${sl<AppConfig>().baseUrl}/storage/chat_files/', ''),
|
widget.imageUrls[index].replaceAll(
|
||||||
|
'${sl<AppConfig>().baseUrl}/storage/chat_files/',
|
||||||
|
'',
|
||||||
|
),
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
@@ -109,7 +133,9 @@ class _PhotoViewScreenState extends State<PhotoViewScreen> {
|
|||||||
width: 20.0,
|
width: 20.0,
|
||||||
height: 20.0,
|
height: 20.0,
|
||||||
child: CircularProgressIndicator(
|
child: CircularProgressIndicator(
|
||||||
value: event == null ? 0 : event.cumulativeBytesLoaded / event.expectedTotalBytes!,
|
value: event == null
|
||||||
|
? 0
|
||||||
|
: event.cumulativeBytesLoaded / event.expectedTotalBytes!,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -6,6 +6,10 @@ import 'package:eatvio_app/features/chat/models/chat_room_pagination_model.dart'
|
|||||||
import 'package:eatvio_app/features/chat/models/order_id_to_conversation_model.dart';
|
import 'package:eatvio_app/features/chat/models/order_id_to_conversation_model.dart';
|
||||||
import 'package:eatvio_app/features/chat/models/payment_model.dart';
|
import 'package:eatvio_app/features/chat/models/payment_model.dart';
|
||||||
import 'package:eatvio_app/features/chat/models/plan_complain_model.dart';
|
import 'package:eatvio_app/features/chat/models/plan_complain_model.dart';
|
||||||
|
import 'package:eatvio_app/features/payment/models/promo_applied_list_response.dart';
|
||||||
|
import 'package:eatvio_app/features/payment/models/promo_apply_response.dart';
|
||||||
|
import 'package:eatvio_app/features/payment/models/promo_remove_response.dart';
|
||||||
|
import 'package:eatvio_app/features/payment/models/promo_validate_response.dart';
|
||||||
import 'package:eatvio_app/features/plans/models/chosen_plan_model.dart';
|
import 'package:eatvio_app/features/plans/models/chosen_plan_model.dart';
|
||||||
import 'package:retrofit/retrofit.dart';
|
import 'package:retrofit/retrofit.dart';
|
||||||
|
|
||||||
@@ -69,6 +73,26 @@ abstract class ChatService {
|
|||||||
@Body() required Map<String, String> body,
|
@Body() required Map<String, String> body,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@GET('/api/chosen_plans/applied_promos')
|
||||||
|
Future<PromoAppliedListResponse> listAppliedPromoCodes();
|
||||||
|
|
||||||
|
@POST('/api/chosen_plans/{id}/validate_promo')
|
||||||
|
Future<PromoValidateResponse> validatePromoCode({
|
||||||
|
@Path() required int id,
|
||||||
|
@Body() required Map<String, String> body,
|
||||||
|
});
|
||||||
|
|
||||||
|
@POST('/api/chosen_plans/{id}/apply_promo')
|
||||||
|
Future<PromoApplyResponse> applyPromoCode({
|
||||||
|
@Path() required int id,
|
||||||
|
@Body() required Map<String, String> body,
|
||||||
|
});
|
||||||
|
|
||||||
|
@DELETE('/api/chosen_plans/{id}/remove_promo_code')
|
||||||
|
Future<PromoRemoveResponse> removePromoCode({
|
||||||
|
@Path() required int id,
|
||||||
|
});
|
||||||
|
|
||||||
@GET('/api/consultation/resolve/order_id_to_conversation/{orderId}')
|
@GET('/api/consultation/resolve/order_id_to_conversation/{orderId}')
|
||||||
Future<OrderIdToConversationModel> resolveOrderIdToConversation({
|
Future<OrderIdToConversationModel> resolveOrderIdToConversation({
|
||||||
@Path() required String orderId,
|
@Path() required String orderId,
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||||
|
|
||||||
|
enum AlfaScoringStatus {
|
||||||
|
@JsonValue('none')
|
||||||
|
none,
|
||||||
|
@JsonValue('beneficiary_creating')
|
||||||
|
beneficiaryCreating,
|
||||||
|
@JsonValue('beneficiary_failed')
|
||||||
|
beneficiaryFailed,
|
||||||
|
@JsonValue('scoring_pending')
|
||||||
|
scoringPending,
|
||||||
|
@JsonValue('scoring_complete')
|
||||||
|
scoringComplete,
|
||||||
|
@JsonValue('scoring_failed')
|
||||||
|
scoringFailed;
|
||||||
|
|
||||||
|
static AlfaScoringStatus fromString(String? value) {
|
||||||
|
switch (value) {
|
||||||
|
case 'beneficiary_creating':
|
||||||
|
return AlfaScoringStatus.beneficiaryCreating;
|
||||||
|
case 'beneficiary_failed':
|
||||||
|
return AlfaScoringStatus.beneficiaryFailed;
|
||||||
|
case 'scoring_pending':
|
||||||
|
return AlfaScoringStatus.scoringPending;
|
||||||
|
case 'scoring_complete':
|
||||||
|
return AlfaScoringStatus.scoringComplete;
|
||||||
|
case 'scoring_failed':
|
||||||
|
return AlfaScoringStatus.scoringFailed;
|
||||||
|
case 'none':
|
||||||
|
case null:
|
||||||
|
case '':
|
||||||
|
return AlfaScoringStatus.none;
|
||||||
|
default:
|
||||||
|
return AlfaScoringStatus.none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool get isProcessing =>
|
||||||
|
this == AlfaScoringStatus.beneficiaryCreating ||
|
||||||
|
this == AlfaScoringStatus.scoringPending;
|
||||||
|
|
||||||
|
bool get isFailed =>
|
||||||
|
this == AlfaScoringStatus.beneficiaryFailed ||
|
||||||
|
this == AlfaScoringStatus.scoringFailed;
|
||||||
|
|
||||||
|
bool get canRetry => isFailed;
|
||||||
|
|
||||||
|
bool get isComplete => this == AlfaScoringStatus.scoringComplete;
|
||||||
|
|
||||||
|
bool get isTerminal => isComplete || isFailed;
|
||||||
|
}
|
||||||
@@ -18,7 +18,8 @@ abstract class ConsultantCertPhotoModel with _$ConsultantCertPhotoModel {
|
|||||||
|
|
||||||
const ConsultantCertPhotoModel._();
|
const ConsultantCertPhotoModel._();
|
||||||
|
|
||||||
factory ConsultantCertPhotoModel.fromJson(Map<String, dynamic> json) => _$ConsultantCertPhotoModelFromJson(json);
|
factory ConsultantCertPhotoModel.fromJson(Map<String, dynamic> json) =>
|
||||||
|
_$ConsultantCertPhotoModelFromJson(json);
|
||||||
|
|
||||||
static const String visibleGroup = 'user_certs';
|
static const String visibleGroup = 'user_certs';
|
||||||
static const String hiddenGroup = 'hidden_user_certs';
|
static const String hiddenGroup = 'hidden_user_certs';
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import 'package:eatvio_app/features/consultant/become_consultant/utils/passport_data_validator.dart';
|
||||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||||
|
|
||||||
part 'consultant_profile_model.freezed.dart';
|
part 'consultant_profile_model.freezed.dart';
|
||||||
@@ -6,27 +7,70 @@ part 'consultant_profile_model.g.dart';
|
|||||||
|
|
||||||
@freezed
|
@freezed
|
||||||
abstract class ConsultantProfileModel with _$ConsultantProfileModel {
|
abstract class ConsultantProfileModel with _$ConsultantProfileModel {
|
||||||
|
const ConsultantProfileModel._();
|
||||||
|
|
||||||
const factory ConsultantProfileModel({
|
const factory ConsultantProfileModel({
|
||||||
@JsonKey(name: 'has_medical_education', includeIfNull: false) bool? hasMedicalEducation,
|
@JsonKey(name: 'has_medical_education', includeIfNull: false)
|
||||||
@JsonKey(name: 'social_links', includeIfNull: false) SocialLinksModel? socialLinks,
|
bool? hasMedicalEducation,
|
||||||
@JsonKey(name: 'cert_photos', includeIfNull: false) List<String>? photos,
|
@JsonKey(name: 'social_links', includeIfNull: false)
|
||||||
@JsonKey(name: 'completed_courses', includeIfNull: false) List<CertificatesModel>? certificates,
|
SocialLinksModel? socialLinks,
|
||||||
|
@JsonKey(name: 'cert_photos', includeIfNull: false) List<String>? photos,
|
||||||
|
@JsonKey(name: 'completed_courses', includeIfNull: false)
|
||||||
|
List<CertificatesModel>? certificates,
|
||||||
|
@JsonKey(name: 'legal_type', includeIfNull: false) String? legalType,
|
||||||
|
@JsonKey(name: 'last_name', includeIfNull: false) String? lastName,
|
||||||
|
@JsonKey(name: 'first_name', includeIfNull: false) String? firstName,
|
||||||
|
@JsonKey(name: 'middle_name', includeIfNull: false) String? middleName,
|
||||||
|
@JsonKey(name: 'inn', includeIfNull: false) String? inn,
|
||||||
|
@JsonKey(name: 'snils', includeIfNull: false) String? snils,
|
||||||
|
@JsonKey(name: 'passport_series', includeIfNull: false)
|
||||||
|
String? passportSeries,
|
||||||
|
@JsonKey(name: 'passport_number', includeIfNull: false)
|
||||||
|
String? passportNumber,
|
||||||
|
@JsonKey(name: 'passport_issue_date', includeIfNull: false)
|
||||||
|
String? passportIssueDate,
|
||||||
|
@JsonKey(name: 'passport_issuer', includeIfNull: false)
|
||||||
|
String? passportIssuer,
|
||||||
|
@JsonKey(name: 'passport_issuer_code', includeIfNull: false)
|
||||||
|
String? passportIssuerCode,
|
||||||
|
@JsonKey(name: 'birth_date', includeIfNull: false) String? birthDate,
|
||||||
|
@JsonKey(name: 'birth_place', includeIfNull: false) String? birthPlace,
|
||||||
|
@JsonKey(name: 'registration_address', includeIfNull: false)
|
||||||
|
String? registrationAddress,
|
||||||
|
@JsonKey(name: 'residence_address', includeIfNull: false)
|
||||||
|
String? residenceAddress,
|
||||||
|
@JsonKey(name: 'citizenship', includeIfNull: false) String? citizenship,
|
||||||
|
@JsonKey(name: 'tax_resident_us', includeIfNull: false) bool? taxResidentUs,
|
||||||
|
@JsonKey(name: 'foreign_tax_resident', includeIfNull: false)
|
||||||
|
bool? foreignTaxResident,
|
||||||
|
@JsonKey(name: 'sbp_phone', includeIfNull: false) String? sbpPhone,
|
||||||
|
@JsonKey(name: 'sbp_bank_member_id', includeIfNull: false)
|
||||||
|
String? sbpBankMemberId,
|
||||||
|
@JsonKey(name: 'agreement_accepted', includeIfNull: false)
|
||||||
|
bool? agreementAccepted,
|
||||||
|
@JsonKey(name: 'frdo_agreement_accepted', includeIfNull: false)
|
||||||
|
bool? frdoAgreementAccepted,
|
||||||
}) = _ConsultantProfileModel;
|
}) = _ConsultantProfileModel;
|
||||||
|
|
||||||
factory ConsultantProfileModel.fromJson(Map<String, Object?> json) => _$ConsultantProfileModelFromJson(json);
|
factory ConsultantProfileModel.fromJson(Map<String, Object?> json) =>
|
||||||
|
_$ConsultantProfileModelFromJson(json);
|
||||||
|
|
||||||
|
bool get hasValidPassportData => PassportDataValidator.validate(this).isEmpty;
|
||||||
}
|
}
|
||||||
|
|
||||||
@freezed
|
@freezed
|
||||||
abstract class SocialLinksModel with _$SocialLinksModel {
|
abstract class SocialLinksModel with _$SocialLinksModel {
|
||||||
const factory SocialLinksModel({
|
const factory SocialLinksModel({
|
||||||
@JsonKey(name: 'vk_link', includeIfNull: false) String? vkLink,
|
@JsonKey(name: 'vk_link', includeIfNull: false) String? vkLink,
|
||||||
@JsonKey(name: 'tg_link', includeIfNull: false) String? tgLink,
|
@JsonKey(name: 'tg_link', includeIfNull: false) String? tgLink,
|
||||||
@JsonKey(name: 'fb_link', includeIfNull: false) String? fbLink,
|
@JsonKey(name: 'fb_link', includeIfNull: false) String? fbLink,
|
||||||
@JsonKey(name: 'insta_link', includeIfNull: false) String? instaLink,
|
@JsonKey(name: 'insta_link', includeIfNull: false) String? instaLink,
|
||||||
@JsonKey(name: 'personal_site_link', includeIfNull: false) String? personalSiteLink,
|
@JsonKey(name: 'personal_site_link', includeIfNull: false)
|
||||||
|
String? personalSiteLink,
|
||||||
}) = _SocialLinksModel;
|
}) = _SocialLinksModel;
|
||||||
|
|
||||||
factory SocialLinksModel.fromJson(Map<String, Object?> json) => _$SocialLinksModelFromJson(json);
|
factory SocialLinksModel.fromJson(Map<String, Object?> json) =>
|
||||||
|
_$SocialLinksModelFromJson(json);
|
||||||
}
|
}
|
||||||
|
|
||||||
@freezed
|
@freezed
|
||||||
@@ -36,5 +80,6 @@ abstract class CertificatesModel with _$CertificatesModel {
|
|||||||
required String date,
|
required String date,
|
||||||
}) = _CertificatesModel;
|
}) = _CertificatesModel;
|
||||||
|
|
||||||
factory CertificatesModel.fromJson(Map<String, Object?> json) => _$CertificatesModelFromJson(json);
|
factory CertificatesModel.fromJson(Map<String, Object?> json) =>
|
||||||
|
_$CertificatesModelFromJson(json);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import 'package:eatvio_app/features/consultant/become_consultant/enums/alfa_scoring_status.dart';
|
||||||
import 'package:eatvio_app/features/profile/models/profile_model.dart';
|
import 'package:eatvio_app/features/profile/models/profile_model.dart';
|
||||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||||
|
|
||||||
@@ -11,26 +12,81 @@ abstract class ConsultationRequestModel with _$ConsultationRequestModel {
|
|||||||
@JsonKey(name: 'rating_place') int? ratingPlace,
|
@JsonKey(name: 'rating_place') int? ratingPlace,
|
||||||
@JsonKey(name: 'education_resource') required EducationModel educationModel,
|
@JsonKey(name: 'education_resource') required EducationModel educationModel,
|
||||||
@JsonKey(name: 'cert_photos') required List<CertPhotosModel> photos,
|
@JsonKey(name: 'cert_photos') required List<CertPhotosModel> photos,
|
||||||
@JsonKey(name: 'canceled_moderation') CanceledModerationModel? canceledModeration,
|
@JsonKey(name: 'canceled_moderation')
|
||||||
|
CanceledModerationModel? canceledModeration,
|
||||||
|
@JsonKey(name: 'legal_entity_resource') LegalEntityModel? legalEntity,
|
||||||
}) = _ConsultationRequestModel;
|
}) = _ConsultationRequestModel;
|
||||||
|
|
||||||
factory ConsultationRequestModel.fromJson(Map<String, dynamic> json) => _$ConsultationRequestModelFromJson(json);
|
factory ConsultationRequestModel.fromJson(Map<String, dynamic> json) =>
|
||||||
|
_$ConsultationRequestModelFromJson(json);
|
||||||
|
}
|
||||||
|
|
||||||
|
@freezed
|
||||||
|
abstract class LegalEntityModel with _$LegalEntityModel {
|
||||||
|
const LegalEntityModel._();
|
||||||
|
|
||||||
|
const factory LegalEntityModel({
|
||||||
|
required int id,
|
||||||
|
required String type,
|
||||||
|
String? inn,
|
||||||
|
String? snils,
|
||||||
|
String? state,
|
||||||
|
@JsonKey(
|
||||||
|
name: 'alfa_scoring_status',
|
||||||
|
unknownEnumValue: AlfaScoringStatus.none,
|
||||||
|
defaultValue: AlfaScoringStatus.none,
|
||||||
|
)
|
||||||
|
@Default(AlfaScoringStatus.none)
|
||||||
|
AlfaScoringStatus alfaScoringStatus,
|
||||||
|
@JsonKey(name: 'alfa_scoring_request_id') String? alfaScoringRequestId,
|
||||||
|
@JsonKey(name: 'last_name') String? lastName,
|
||||||
|
@JsonKey(name: 'first_name') String? firstName,
|
||||||
|
@JsonKey(name: 'middle_name') String? middleName,
|
||||||
|
@JsonKey(name: 'passport_series') String? passportSeries,
|
||||||
|
@JsonKey(name: 'passport_number') String? passportNumber,
|
||||||
|
@JsonKey(name: 'passport_issue_date') String? passportIssueDate,
|
||||||
|
@JsonKey(name: 'passport_issuer') String? passportIssuer,
|
||||||
|
@JsonKey(name: 'passport_issuer_code') String? passportIssuerCode,
|
||||||
|
@JsonKey(name: 'birth_date') String? birthDate,
|
||||||
|
@JsonKey(name: 'birth_place') String? birthPlace,
|
||||||
|
@JsonKey(name: 'registration_address') String? registrationAddress,
|
||||||
|
@JsonKey(name: 'residence_address') String? residenceAddress,
|
||||||
|
String? citizenship,
|
||||||
|
@JsonKey(name: 'tax_resident_us') bool? taxResidentUs,
|
||||||
|
@JsonKey(name: 'foreign_tax_resident') bool? foreignTaxResident,
|
||||||
|
@JsonKey(name: 'sbp_phone') String? sbpPhone,
|
||||||
|
@JsonKey(name: 'sbp_bank_member_id') String? sbpBankMemberId,
|
||||||
|
}) = _LegalEntityModel;
|
||||||
|
|
||||||
|
factory LegalEntityModel.fromJson(Map<String, dynamic> json) =>
|
||||||
|
_$LegalEntityModelFromJson(json);
|
||||||
|
|
||||||
|
/// Normalizes API date (`Y-m-d` or ISO datetime) to `Y-m-d`.
|
||||||
|
static String? normalizeApiDate(String? value) {
|
||||||
|
if (value == null || value.isEmpty) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (value.length >= 10 && RegExp(r'^\d{4}-\d{2}-\d{2}').hasMatch(value)) {
|
||||||
|
return value.substring(0, 10);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
String? get normalizedPassportIssueDate =>
|
||||||
|
normalizeApiDate(passportIssueDate);
|
||||||
|
|
||||||
|
String? get normalizedBirthDate => normalizeApiDate(birthDate);
|
||||||
}
|
}
|
||||||
|
|
||||||
@freezed
|
@freezed
|
||||||
abstract class CertPhotosModel with _$CertPhotosModel {
|
abstract class CertPhotosModel with _$CertPhotosModel {
|
||||||
const factory CertPhotosModel({
|
const factory CertPhotosModel({required String url}) = _CertPhotosModel;
|
||||||
required String url,
|
|
||||||
}) = _CertPhotosModel;
|
|
||||||
|
|
||||||
factory CertPhotosModel.fromJson(Map<String, dynamic> json) => _$CertPhotosModelFromJson(json);
|
factory CertPhotosModel.fromJson(Map<String, dynamic> json) =>
|
||||||
|
_$CertPhotosModelFromJson(json);
|
||||||
}
|
}
|
||||||
|
|
||||||
enum ModerationStatus {
|
enum ModerationStatus { approved, created, canceled }
|
||||||
approved,
|
|
||||||
created,
|
|
||||||
canceled
|
|
||||||
}
|
|
||||||
|
|
||||||
@freezed
|
@freezed
|
||||||
abstract class CanceledModerationModel with _$CanceledModerationModel {
|
abstract class CanceledModerationModel with _$CanceledModerationModel {
|
||||||
@@ -40,5 +96,6 @@ abstract class CanceledModerationModel with _$CanceledModerationModel {
|
|||||||
required List<String>? additional,
|
required List<String>? additional,
|
||||||
}) = _CanceledModerationModel;
|
}) = _CanceledModerationModel;
|
||||||
|
|
||||||
factory CanceledModerationModel.fromJson(Map<String, dynamic> json) => _$CanceledModerationModelFromJson(json);
|
factory CanceledModerationModel.fromJson(Map<String, dynamic> json) =>
|
||||||
|
_$CanceledModerationModelFromJson(json);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||||
|
|
||||||
|
part 'sbp_bank_model.freezed.dart';
|
||||||
|
part 'sbp_bank_model.g.dart';
|
||||||
|
|
||||||
|
@freezed
|
||||||
|
abstract class SbpBankModel with _$SbpBankModel {
|
||||||
|
const factory SbpBankModel({
|
||||||
|
@JsonKey(name: 'bank_member_id') required String bankMemberId,
|
||||||
|
@JsonKey(name: 'bank_name') required String bankName,
|
||||||
|
@JsonKey(name: 'short_name') String? shortName,
|
||||||
|
@JsonKey(name: 'logo_url') String? logoUrl,
|
||||||
|
String? bic,
|
||||||
|
}) = _SbpBankModel;
|
||||||
|
|
||||||
|
factory SbpBankModel.fromJson(Map<String, dynamic> json) =>
|
||||||
|
_$SbpBankModelFromJson(json);
|
||||||
|
}
|
||||||
@@ -1,42 +1,118 @@
|
|||||||
|
import 'dart:async';
|
||||||
import 'dart:developer';
|
import 'dart:developer';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:dio/dio.dart';
|
||||||
|
import 'package:eatvio_app/core/error_reporting/report_from.dart';
|
||||||
import 'package:eatvio_app/core/image_compressing/image_compress_utility.dart';
|
import 'package:eatvio_app/core/image_compressing/image_compress_utility.dart';
|
||||||
|
import 'package:eatvio_app/features/consultant/become_consultant/enums/alfa_scoring_status.dart';
|
||||||
import 'package:eatvio_app/features/consultant/become_consultant/models/consultant_profile_model.dart';
|
import 'package:eatvio_app/features/consultant/become_consultant/models/consultant_profile_model.dart';
|
||||||
|
import 'package:eatvio_app/features/consultant/become_consultant/models/consultation_request_model.dart';
|
||||||
|
import 'package:eatvio_app/features/consultant/become_consultant/models/sbp_bank_model.dart';
|
||||||
import 'package:eatvio_app/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_state.dart';
|
import 'package:eatvio_app/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_state.dart';
|
||||||
import 'package:eatvio_app/features/consultant/become_consultant/repositories/consultant_repository.dart';
|
import 'package:eatvio_app/features/consultant/become_consultant/repositories/consultant_repository.dart';
|
||||||
|
import 'package:eatvio_app/features/consultant/become_consultant/utils/laravel_validation_errors.dart';
|
||||||
|
import 'package:eatvio_app/features/consultant/become_consultant/utils/passport_data_validator.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:eatvio_app/core/error_reporting/report_from.dart';
|
|
||||||
|
|
||||||
class RegisterConsultantCubit extends Cubit<RegisterConsultantState> {
|
class RegisterConsultantCubit extends Cubit<RegisterConsultantState> {
|
||||||
final ConsultantRepository consultantRepository;
|
final ConsultantRepository consultantRepository;
|
||||||
|
final Duration scoringPollInterval;
|
||||||
bool _update = false;
|
bool _update = false;
|
||||||
|
String? _userSlug;
|
||||||
|
Timer? _scoringPollTimer;
|
||||||
|
|
||||||
RegisterConsultantCubit({required this.consultantRepository})
|
static const String individualPersonLegalType = 'individualPerson';
|
||||||
: super(
|
|
||||||
const RegisterConsultantState(
|
RegisterConsultantCubit({
|
||||||
consultant: ConsultantProfileModel(socialLinks: SocialLinksModel(), hasMedicalEducation: true),
|
required this.consultantRepository,
|
||||||
loading: true,
|
this.scoringPollInterval = const Duration(seconds: 3),
|
||||||
acceptOffer: false,
|
}) : super(
|
||||||
isUpdating: false,
|
const RegisterConsultantState(
|
||||||
),
|
consultant: ConsultantProfileModel(
|
||||||
);
|
socialLinks: SocialLinksModel(),
|
||||||
|
hasMedicalEducation: true,
|
||||||
|
legalType: individualPersonLegalType,
|
||||||
|
),
|
||||||
|
loading: true,
|
||||||
|
acceptOffer: false,
|
||||||
|
acceptFrdoAgreement: false,
|
||||||
|
isUpdating: false,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
String? get userSlug => _userSlug;
|
||||||
|
|
||||||
|
void setUserSlug(String? slug) {
|
||||||
|
if (slug != null && slug.isNotEmpty) {
|
||||||
|
_userSlug = slug;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<SbpBankModel>> _loadSbpBanks() async {
|
||||||
|
try {
|
||||||
|
return await consultantRepository.getSbpBanks();
|
||||||
|
} catch (e, st) {
|
||||||
|
await reportFrom(e, st, this);
|
||||||
|
log(e.toString());
|
||||||
|
return const <SbpBankModel>[];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
List<SbpBankModel> searchSbpBanks(String query) {
|
||||||
|
final normalized = query.trim().toLowerCase();
|
||||||
|
if (normalized.isEmpty) {
|
||||||
|
return state.sbpBanks;
|
||||||
|
}
|
||||||
|
return state.sbpBanks.where((bank) {
|
||||||
|
final name = bank.bankName.toLowerCase();
|
||||||
|
final shortName = (bank.shortName ?? '').toLowerCase();
|
||||||
|
final bic = (bank.bic ?? '').toLowerCase();
|
||||||
|
return name.contains(normalized) ||
|
||||||
|
shortName.contains(normalized) ||
|
||||||
|
bic.contains(normalized);
|
||||||
|
}).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
SbpBankModel? findSbpBankById(String? bankMemberId) {
|
||||||
|
if (bankMemberId == null || bankMemberId.isEmpty) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
for (final bank in state.sbpBanks) {
|
||||||
|
if (bank.bankMemberId == bankMemberId) {
|
||||||
|
return bank;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> getMyRequestStatus(String slug) async {
|
Future<void> getMyRequestStatus(String slug) async {
|
||||||
try {
|
try {
|
||||||
_update = true;
|
_update = true;
|
||||||
final myRequestStatus = await consultantRepository.getMyRequestStatus(slug);
|
_userSlug = slug;
|
||||||
|
final myRequestStatus = await consultantRepository.getMyRequestStatus(
|
||||||
|
slug,
|
||||||
|
);
|
||||||
final certPhotos = await consultantRepository.getConsultantCertPhotos();
|
final certPhotos = await consultantRepository.getConsultantCertPhotos();
|
||||||
|
final sbpBanks = await _loadSbpBanks();
|
||||||
|
|
||||||
List<CertificatesModel>? certificates = myRequestStatus.educationModel.completedCourses?.map((courseMap) {
|
List<CertificatesModel>? certificates = myRequestStatus
|
||||||
final course = courseMap.values.first;
|
.educationModel
|
||||||
|
.completedCourses
|
||||||
|
?.map((courseMap) {
|
||||||
|
final course = courseMap.values.first;
|
||||||
|
|
||||||
return CertificatesModel(title: course.title, date: course.date);
|
return CertificatesModel(title: course.title, date: course.date);
|
||||||
}).toList();
|
})
|
||||||
|
.toList();
|
||||||
|
|
||||||
String? sanitizeLink(String? link) {
|
String? sanitizeLink(String? link) {
|
||||||
return (link != null && link.isNotEmpty) ? link : null;
|
return (link != null && link.isNotEmpty) ? link : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final legal = myRequestStatus.legalEntity;
|
||||||
|
final scoringStatus = legal?.alfaScoringStatus ?? AlfaScoringStatus.none;
|
||||||
|
|
||||||
emit(
|
emit(
|
||||||
RegisterConsultantState(
|
RegisterConsultantState(
|
||||||
consultant: ConsultantProfileModel(
|
consultant: ConsultantProfileModel(
|
||||||
@@ -46,33 +122,107 @@ class RegisterConsultantCubit extends Cubit<RegisterConsultantState> {
|
|||||||
tgLink: sanitizeLink(myRequestStatus.educationModel.tgLink),
|
tgLink: sanitizeLink(myRequestStatus.educationModel.tgLink),
|
||||||
fbLink: sanitizeLink(myRequestStatus.educationModel.fbLink),
|
fbLink: sanitizeLink(myRequestStatus.educationModel.fbLink),
|
||||||
instaLink: sanitizeLink(myRequestStatus.educationModel.instaLink),
|
instaLink: sanitizeLink(myRequestStatus.educationModel.instaLink),
|
||||||
personalSiteLink: sanitizeLink(myRequestStatus.educationModel.personalSiteLink),
|
personalSiteLink: sanitizeLink(
|
||||||
|
myRequestStatus.educationModel.personalSiteLink,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
hasMedicalEducation: myRequestStatus.educationModel.hasMedicalEducation == 1,
|
hasMedicalEducation:
|
||||||
|
myRequestStatus.educationModel.hasMedicalEducation == 1,
|
||||||
|
legalType: legal?.type ?? individualPersonLegalType,
|
||||||
|
lastName: _emptyToNull(legal?.lastName),
|
||||||
|
firstName: _emptyToNull(legal?.firstName),
|
||||||
|
middleName: _emptyToNull(legal?.middleName),
|
||||||
|
inn: _emptyToNull(legal?.inn),
|
||||||
|
snils: _emptyToNull(legal?.snils),
|
||||||
|
passportSeries: _emptyToNull(legal?.passportSeries),
|
||||||
|
passportNumber: _emptyToNull(legal?.passportNumber),
|
||||||
|
passportIssueDate: legal?.normalizedPassportIssueDate,
|
||||||
|
passportIssuer: _emptyToNull(legal?.passportIssuer),
|
||||||
|
passportIssuerCode: _emptyToNull(legal?.passportIssuerCode),
|
||||||
|
birthDate: legal?.normalizedBirthDate,
|
||||||
|
birthPlace: _emptyToNull(legal?.birthPlace),
|
||||||
|
registrationAddress: _emptyToNull(legal?.registrationAddress),
|
||||||
|
residenceAddress: _emptyToNull(legal?.residenceAddress),
|
||||||
|
citizenship: _emptyToNull(legal?.citizenship),
|
||||||
|
taxResidentUs: legal?.taxResidentUs ?? false,
|
||||||
|
foreignTaxResident: legal?.foreignTaxResident ?? false,
|
||||||
|
sbpPhone: _emptyToNull(legal?.sbpPhone),
|
||||||
|
sbpBankMemberId: _emptyToNull(legal?.sbpBankMemberId),
|
||||||
),
|
),
|
||||||
acceptOffer: false,
|
acceptOffer: false,
|
||||||
|
acceptFrdoAgreement: false,
|
||||||
loading: false,
|
loading: false,
|
||||||
requestModel: myRequestStatus,
|
requestModel: myRequestStatus,
|
||||||
certPhotos: certPhotos,
|
certPhotos: certPhotos,
|
||||||
|
sbpBanks: sbpBanks,
|
||||||
isUpdating: _update,
|
isUpdating: _update,
|
||||||
|
alfaScoringStatus: scoringStatus,
|
||||||
|
isScoringPolling: scoringStatus.isProcessing,
|
||||||
|
scoringRetryInProgress: false,
|
||||||
|
awaitingScoringOutcome: false,
|
||||||
|
fieldErrors: const <String, String>{},
|
||||||
error: null,
|
error: null,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (scoringStatus.isProcessing) {
|
||||||
|
startScoringPoll();
|
||||||
|
}
|
||||||
|
} catch (e, st) {
|
||||||
|
await reportFrom(e, st, this);
|
||||||
|
log(e.toString());
|
||||||
|
final parsed = parseLaravelValidationErrors(e);
|
||||||
|
emit(
|
||||||
|
state.copyWith(
|
||||||
|
loading: false,
|
||||||
|
error: parsed.summary,
|
||||||
|
fieldErrors: parsed.fieldErrors,
|
||||||
|
awaitingScoringOutcome: false,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String? _emptyToNull(String? value) {
|
||||||
|
if (value == null || value.trim().isEmpty) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return value.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> disableLoading({String? slug}) async {
|
||||||
|
try {
|
||||||
|
setUserSlug(slug);
|
||||||
|
final certPhotos = await consultantRepository.getConsultantCertPhotos();
|
||||||
|
final sbpBanks = await _loadSbpBanks();
|
||||||
|
emit(
|
||||||
|
state.copyWith(
|
||||||
|
loading: false,
|
||||||
|
certPhotos: certPhotos,
|
||||||
|
sbpBanks: sbpBanks,
|
||||||
|
error: null,
|
||||||
|
fieldErrors: const <String, String>{},
|
||||||
|
awaitingScoringOutcome: false,
|
||||||
|
consultant: state.consultant.copyWith(
|
||||||
|
legalType: individualPersonLegalType,
|
||||||
|
citizenship: state.consultant.citizenship,
|
||||||
|
taxResidentUs: state.consultant.taxResidentUs ?? false,
|
||||||
|
foreignTaxResident: state.consultant.foreignTaxResident ?? false,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
} catch (e, st) {
|
} catch (e, st) {
|
||||||
await reportFrom(e, st, this);
|
await reportFrom(e, st, this);
|
||||||
log(e.toString());
|
log(e.toString());
|
||||||
emit(state.copyWith(loading: false, error: e.toString()));
|
final parsed = parseLaravelValidationErrors(e);
|
||||||
}
|
emit(
|
||||||
}
|
state.copyWith(
|
||||||
|
loading: false,
|
||||||
Future<void> disableLoading() async {
|
error: parsed.summary,
|
||||||
try {
|
fieldErrors: parsed.fieldErrors,
|
||||||
final certPhotos = await consultantRepository.getConsultantCertPhotos();
|
awaitingScoringOutcome: false,
|
||||||
emit(state.copyWith(loading: false, certPhotos: certPhotos, error: null));
|
),
|
||||||
} catch (e, st) {
|
);
|
||||||
await reportFrom(e, st, this);
|
|
||||||
log(e.toString());
|
|
||||||
emit(state.copyWith(loading: false, error: e.toString()));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,41 +232,326 @@ class RegisterConsultantCubit extends Cubit<RegisterConsultantState> {
|
|||||||
if (slug != null) {
|
if (slug != null) {
|
||||||
await getMyRequestStatus(slug);
|
await getMyRequestStatus(slug);
|
||||||
} else {
|
} else {
|
||||||
await disableLoading();
|
await disableLoading(slug: _userSlug);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void updateConsultantProfileModel(ConsultantProfileModel consultant, [bool acceptOffer = false]) {
|
void updateConsultantProfileModel(
|
||||||
emit(state.copyWith(consultant: consultant, acceptOffer: acceptOffer, loading: false, error: null));
|
ConsultantProfileModel consultant, [
|
||||||
|
bool? acceptOffer,
|
||||||
|
bool? acceptFrdoAgreement,
|
||||||
|
]) {
|
||||||
|
final nextFieldErrors = state.fieldErrors.isEmpty
|
||||||
|
? const <String, String>{}
|
||||||
|
: PassportDataValidator.validate(consultant);
|
||||||
|
emit(
|
||||||
|
state.copyWith(
|
||||||
|
consultant: consultant,
|
||||||
|
acceptOffer: acceptOffer ?? state.acceptOffer,
|
||||||
|
acceptFrdoAgreement: acceptFrdoAgreement ?? state.acceptFrdoAgreement,
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
fieldErrors: nextFieldErrors,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, String> validatePassportFields() {
|
||||||
|
final errors = PassportDataValidator.validate(state.consultant);
|
||||||
|
emit(state.copyWith(fieldErrors: errors, error: null));
|
||||||
|
return errors;
|
||||||
|
}
|
||||||
|
|
||||||
|
void updateAgreements({bool? acceptOffer, bool? acceptFrdoAgreement}) {
|
||||||
|
emit(
|
||||||
|
state.copyWith(
|
||||||
|
acceptOffer: acceptOffer ?? state.acceptOffer,
|
||||||
|
acceptFrdoAgreement: acceptFrdoAgreement ?? state.acceptFrdoAgreement,
|
||||||
|
error: null,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool get hasAcceptedRequiredAgreements {
|
||||||
|
if (_update) {
|
||||||
|
return state.acceptOffer;
|
||||||
|
}
|
||||||
|
return state.acceptOffer && state.acceptFrdoAgreement;
|
||||||
|
}
|
||||||
|
|
||||||
|
ConsultantProfileModel buildSubmitPayload() {
|
||||||
|
final withLegalType = state.consultant.copyWith(
|
||||||
|
legalType: individualPersonLegalType,
|
||||||
|
citizenship: state.consultant.citizenship,
|
||||||
|
taxResidentUs: state.consultant.taxResidentUs ?? false,
|
||||||
|
foreignTaxResident: state.consultant.foreignTaxResident ?? false,
|
||||||
|
certificates: state.consultant.certificates
|
||||||
|
?.map(
|
||||||
|
(course) => CertificatesModel(
|
||||||
|
title: course.title,
|
||||||
|
date:
|
||||||
|
LegalEntityModel.normalizeApiDate(course.date) ?? course.date,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toList(),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (_update) {
|
||||||
|
return ConsultantProfileModel(
|
||||||
|
hasMedicalEducation: withLegalType.hasMedicalEducation,
|
||||||
|
socialLinks: withLegalType.socialLinks,
|
||||||
|
photos: withLegalType.photos,
|
||||||
|
certificates: withLegalType.certificates,
|
||||||
|
legalType: withLegalType.legalType,
|
||||||
|
lastName: withLegalType.lastName,
|
||||||
|
firstName: withLegalType.firstName,
|
||||||
|
middleName: withLegalType.middleName,
|
||||||
|
inn: withLegalType.inn,
|
||||||
|
snils: withLegalType.snils,
|
||||||
|
passportSeries: withLegalType.passportSeries,
|
||||||
|
passportNumber: withLegalType.passportNumber,
|
||||||
|
passportIssueDate: withLegalType.passportIssueDate,
|
||||||
|
passportIssuer: withLegalType.passportIssuer,
|
||||||
|
passportIssuerCode: withLegalType.passportIssuerCode,
|
||||||
|
birthDate: withLegalType.birthDate,
|
||||||
|
birthPlace: withLegalType.birthPlace,
|
||||||
|
registrationAddress: withLegalType.registrationAddress,
|
||||||
|
residenceAddress: withLegalType.residenceAddress,
|
||||||
|
citizenship: withLegalType.citizenship,
|
||||||
|
taxResidentUs: withLegalType.taxResidentUs,
|
||||||
|
foreignTaxResident: withLegalType.foreignTaxResident,
|
||||||
|
sbpPhone: withLegalType.sbpPhone,
|
||||||
|
sbpBankMemberId: withLegalType.sbpBankMemberId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return withLegalType.copyWith(
|
||||||
|
agreementAccepted: state.acceptOffer,
|
||||||
|
frdoAgreementAccepted: state.acceptFrdoAgreement,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> registerConsultant() async {
|
Future<void> registerConsultant() async {
|
||||||
emit(state.copyWith(error: null));
|
final passportErrors = validatePassportFields();
|
||||||
|
if (passportErrors.isNotEmpty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show scoring widget immediately while request / job are in progress.
|
||||||
|
emit(
|
||||||
|
state.copyWith(
|
||||||
|
error: null,
|
||||||
|
fieldErrors: const <String, String>{},
|
||||||
|
alfaScoringStatus: AlfaScoringStatus.beneficiaryCreating,
|
||||||
|
awaitingScoringOutcome: true,
|
||||||
|
scoringRetryInProgress: false,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (state.newAddedPhotos.isNotEmpty) {
|
if (state.newAddedPhotos.isNotEmpty) {
|
||||||
await consultantRepository.storeConsultantCertPhotos(files: state.newAddedPhotos);
|
await consultantRepository.storeConsultantCertPhotos(
|
||||||
|
files: state.newAddedPhotos,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (state.deletedPhotoIds.isNotEmpty) {
|
if (state.deletedPhotoIds.isNotEmpty) {
|
||||||
for (final int id in state.deletedPhotoIds) {
|
for (final int id in state.deletedPhotoIds) {
|
||||||
await consultantRepository.deletePhoto(id);
|
await consultantRepository.deletePhoto(id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
await consultantRepository.registerConsultant(consultant: state.consultant, update: _update);
|
await consultantRepository.registerConsultant(
|
||||||
|
consultant: buildSubmitPayload(),
|
||||||
|
update: _update,
|
||||||
|
);
|
||||||
|
_update = true;
|
||||||
|
emit(
|
||||||
|
state.copyWith(
|
||||||
|
isUpdating: true,
|
||||||
|
alfaScoringStatus: AlfaScoringStatus.beneficiaryCreating,
|
||||||
|
isScoringPolling: true,
|
||||||
|
scoringRetryInProgress: false,
|
||||||
|
awaitingScoringOutcome: true,
|
||||||
|
newAddedPhotos: const <File>[],
|
||||||
|
deletedPhotoIds: const <int>[],
|
||||||
|
fieldErrors: const <String, String>{},
|
||||||
|
error: null,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
startScoringPoll();
|
||||||
|
} on DioException catch (e, st) {
|
||||||
|
await reportFrom(e, st, this);
|
||||||
|
log(e.toString());
|
||||||
|
final parsed = parseLaravelValidationErrors(e);
|
||||||
|
emit(
|
||||||
|
state.copyWith(
|
||||||
|
error: parsed.summary,
|
||||||
|
fieldErrors: parsed.fieldErrors,
|
||||||
|
scoringRetryInProgress: false,
|
||||||
|
awaitingScoringOutcome: false,
|
||||||
|
isScoringPolling: false,
|
||||||
|
alfaScoringStatus: AlfaScoringStatus.none,
|
||||||
|
),
|
||||||
|
);
|
||||||
} catch (e, st) {
|
} catch (e, st) {
|
||||||
await reportFrom(e, st, this);
|
await reportFrom(e, st, this);
|
||||||
log(e.toString());
|
log(e.toString());
|
||||||
emit(state.copyWith(error: e.toString()));
|
emit(
|
||||||
|
state.copyWith(
|
||||||
|
error: e.toString(),
|
||||||
|
scoringRetryInProgress: false,
|
||||||
|
awaitingScoringOutcome: false,
|
||||||
|
isScoringPolling: false,
|
||||||
|
alfaScoringStatus: AlfaScoringStatus.none,
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> retryScoring() async {
|
||||||
|
if (!state.alfaScoringStatus.canRetry || state.scoringRetryInProgress) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final passportErrors = validatePassportFields();
|
||||||
|
if (passportErrors.isNotEmpty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
emit(
|
||||||
|
state.copyWith(
|
||||||
|
scoringRetryInProgress: true,
|
||||||
|
error: null,
|
||||||
|
fieldErrors: const <String, String>{},
|
||||||
|
alfaScoringStatus: AlfaScoringStatus.beneficiaryCreating,
|
||||||
|
isScoringPolling: true,
|
||||||
|
awaitingScoringOutcome: true,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
_update = true;
|
||||||
|
try {
|
||||||
|
await consultantRepository.registerConsultant(
|
||||||
|
consultant: buildSubmitPayload(),
|
||||||
|
update: true,
|
||||||
|
);
|
||||||
|
startScoringPoll();
|
||||||
|
emit(
|
||||||
|
state.copyWith(
|
||||||
|
scoringRetryInProgress: false,
|
||||||
|
isUpdating: true,
|
||||||
|
awaitingScoringOutcome: true,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} on DioException catch (e, st) {
|
||||||
|
await reportFrom(e, st, this);
|
||||||
|
log(e.toString());
|
||||||
|
final parsed = parseLaravelValidationErrors(e);
|
||||||
|
emit(
|
||||||
|
state.copyWith(
|
||||||
|
scoringRetryInProgress: false,
|
||||||
|
isScoringPolling: false,
|
||||||
|
awaitingScoringOutcome: false,
|
||||||
|
alfaScoringStatus: AlfaScoringStatus.scoringFailed,
|
||||||
|
error: parsed.summary,
|
||||||
|
fieldErrors: parsed.fieldErrors,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} catch (e, st) {
|
||||||
|
await reportFrom(e, st, this);
|
||||||
|
log(e.toString());
|
||||||
|
emit(
|
||||||
|
state.copyWith(
|
||||||
|
scoringRetryInProgress: false,
|
||||||
|
isScoringPolling: false,
|
||||||
|
awaitingScoringOutcome: false,
|
||||||
|
alfaScoringStatus: AlfaScoringStatus.scoringFailed,
|
||||||
|
error: e.toString(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void startScoringPoll() {
|
||||||
|
final slug = _userSlug;
|
||||||
|
if (slug == null || slug.isEmpty) {
|
||||||
|
emit(
|
||||||
|
state.copyWith(isScoringPolling: false, awaitingScoringOutcome: false),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_stopScoringPoll();
|
||||||
|
emit(state.copyWith(isScoringPolling: true));
|
||||||
|
_scoringPollTimer = Timer.periodic(
|
||||||
|
scoringPollInterval,
|
||||||
|
(_) => _pollScoringStatus(slug),
|
||||||
|
);
|
||||||
|
unawaited(_pollScoringStatus(slug));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _pollScoringStatus(String slug) async {
|
||||||
|
if (isClosed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
final status = await consultantRepository.getMyRequestStatus(slug);
|
||||||
|
if (isClosed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final scoringStatus =
|
||||||
|
status.legalEntity?.alfaScoringStatus ?? AlfaScoringStatus.none;
|
||||||
|
// API stays on `none` until ProcessConsultantScoringJob starts
|
||||||
|
// (ScoringStarted → beneficiary_creating). Keep optimistic processing
|
||||||
|
// UI and continue polling until a real terminal/processing status arrives.
|
||||||
|
if (scoringStatus == AlfaScoringStatus.none) {
|
||||||
|
emit(
|
||||||
|
state.copyWith(
|
||||||
|
requestModel: status,
|
||||||
|
alfaScoringStatus: AlfaScoringStatus.beneficiaryCreating,
|
||||||
|
isScoringPolling: true,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final isTerminal = scoringStatus.isTerminal;
|
||||||
|
emit(
|
||||||
|
state.copyWith(
|
||||||
|
requestModel: status,
|
||||||
|
alfaScoringStatus: scoringStatus,
|
||||||
|
isScoringPolling: scoringStatus.isProcessing,
|
||||||
|
// Clear session flag on terminal so success sheet can fire once
|
||||||
|
// (listenWhen: previous.awaiting && !current.awaiting && complete).
|
||||||
|
awaitingScoringOutcome: isTerminal
|
||||||
|
? false
|
||||||
|
: state.awaitingScoringOutcome,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (!scoringStatus.isProcessing) {
|
||||||
|
_stopScoringPoll();
|
||||||
|
}
|
||||||
|
} catch (e, st) {
|
||||||
|
await reportFrom(e, st, this);
|
||||||
|
log(e.toString());
|
||||||
|
// Keep polling on transient errors while scoring is in progress.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _stopScoringPoll() {
|
||||||
|
_scoringPollTimer?.cancel();
|
||||||
|
_scoringPollTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> addCertPhotos(List<File> files) async {
|
Future<void> addCertPhotos(List<File> files) async {
|
||||||
if (files.isEmpty) {
|
if (files.isEmpty) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
final compressedFiles = await ImageCompressUtility.compressFilesList(files);
|
final compressedFiles = await ImageCompressUtility.compressFilesList(
|
||||||
|
files,
|
||||||
|
);
|
||||||
|
|
||||||
emit(state.copyWith(newAddedPhotos: [...state.newAddedPhotos, ...compressedFiles], error: null));
|
emit(
|
||||||
|
state.copyWith(
|
||||||
|
newAddedPhotos: [...state.newAddedPhotos, ...compressedFiles],
|
||||||
|
error: null,
|
||||||
|
),
|
||||||
|
);
|
||||||
} catch (e, st) {
|
} catch (e, st) {
|
||||||
await reportFrom(e, st, this);
|
await reportFrom(e, st, this);
|
||||||
log(e.toString());
|
log(e.toString());
|
||||||
@@ -130,7 +565,19 @@ class RegisterConsultantCubit extends Cubit<RegisterConsultantState> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (path != null) {
|
if (path != null) {
|
||||||
emit(state.copyWith(newAddedPhotos: state.newAddedPhotos.where((file) => file.uri.path != path).toList()));
|
emit(
|
||||||
|
state.copyWith(
|
||||||
|
newAddedPhotos: state.newAddedPhotos
|
||||||
|
.where((file) => file.uri.path != path)
|
||||||
|
.toList(),
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> close() {
|
||||||
|
_stopScoringPoll();
|
||||||
|
return super.close();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:eatvio_app/features/consultant/become_consultant/enums/alfa_scoring_status.dart';
|
||||||
import 'package:eatvio_app/features/consultant/become_consultant/models/consultant_cert_photo_model.dart';
|
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/models/consultant_profile_model.dart';
|
||||||
import 'package:eatvio_app/features/consultant/become_consultant/models/consultation_request_model.dart';
|
import 'package:eatvio_app/features/consultant/become_consultant/models/consultation_request_model.dart';
|
||||||
|
import 'package:eatvio_app/features/consultant/become_consultant/models/sbp_bank_model.dart';
|
||||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||||
|
|
||||||
part 'register_consultant_state.freezed.dart';
|
part 'register_consultant_state.freezed.dart';
|
||||||
@@ -12,12 +14,22 @@ abstract class RegisterConsultantState with _$RegisterConsultantState {
|
|||||||
const factory RegisterConsultantState({
|
const factory RegisterConsultantState({
|
||||||
required ConsultantProfileModel consultant,
|
required ConsultantProfileModel consultant,
|
||||||
required bool acceptOffer,
|
required bool acceptOffer,
|
||||||
|
@Default(false) bool acceptFrdoAgreement,
|
||||||
required bool loading,
|
required bool loading,
|
||||||
required bool isUpdating,
|
required bool isUpdating,
|
||||||
ConsultationRequestModel? requestModel,
|
ConsultationRequestModel? requestModel,
|
||||||
@Default(<ConsultantCertPhotoModel>[]) List<ConsultantCertPhotoModel> certPhotos,
|
@Default(<ConsultantCertPhotoModel>[])
|
||||||
|
List<ConsultantCertPhotoModel> certPhotos,
|
||||||
|
@Default(<SbpBankModel>[]) List<SbpBankModel> sbpBanks,
|
||||||
@Default(<File>[]) List<File> newAddedPhotos,
|
@Default(<File>[]) List<File> newAddedPhotos,
|
||||||
@Default(<int>[]) List<int> deletedPhotoIds,
|
@Default(<int>[]) List<int> deletedPhotoIds,
|
||||||
|
@Default(AlfaScoringStatus.none) AlfaScoringStatus alfaScoringStatus,
|
||||||
|
@Default(false) bool isScoringPolling,
|
||||||
|
@Default(false) bool scoringRetryInProgress,
|
||||||
|
|
||||||
|
/// True only after successful register/retry in this session until scoring is terminal.
|
||||||
|
@Default(false) bool awaitingScoringOutcome,
|
||||||
|
@Default(<String, String>{}) Map<String, String> fieldErrors,
|
||||||
String? error,
|
String? error,
|
||||||
}) = _RegisterConsultantState;
|
}) = _RegisterConsultantState;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,8 @@ class AdditionalSettingsScreen extends StatefulWidget {
|
|||||||
const AdditionalSettingsScreen({super.key});
|
const AdditionalSettingsScreen({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<AdditionalSettingsScreen> createState() => _AdditionalSettingsScreenState();
|
State<AdditionalSettingsScreen> createState() =>
|
||||||
|
_AdditionalSettingsScreenState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _AdditionalSettingsScreenState extends State<AdditionalSettingsScreen> {
|
class _AdditionalSettingsScreenState extends State<AdditionalSettingsScreen> {
|
||||||
@@ -17,7 +18,12 @@ class _AdditionalSettingsScreenState extends State<AdditionalSettingsScreen> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
if (context.read<RegisterConsultantCubit>().state.consultant.hasMedicalEducation != null) {
|
if (context
|
||||||
|
.read<RegisterConsultantCubit>()
|
||||||
|
.state
|
||||||
|
.consultant
|
||||||
|
.hasMedicalEducation !=
|
||||||
|
null) {
|
||||||
haveMedicalDegree = context
|
haveMedicalDegree = context
|
||||||
.read<RegisterConsultantCubit>()
|
.read<RegisterConsultantCubit>()
|
||||||
.state
|
.state
|
||||||
@@ -52,7 +58,8 @@ class _AdditionalSettingsScreenState extends State<AdditionalSettingsScreen> {
|
|||||||
value: publishDiplomasAndCertificatesInProfile,
|
value: publishDiplomasAndCertificatesInProfile,
|
||||||
onTap: () {
|
onTap: () {
|
||||||
setState(() {
|
setState(() {
|
||||||
publishDiplomasAndCertificatesInProfile = !publishDiplomasAndCertificatesInProfile;
|
publishDiplomasAndCertificatesInProfile =
|
||||||
|
!publishDiplomasAndCertificatesInProfile;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@@ -64,11 +71,15 @@ class _AdditionalSettingsScreenState extends State<AdditionalSettingsScreen> {
|
|||||||
setState(() {
|
setState(() {
|
||||||
haveMedicalDegree = !haveMedicalDegree;
|
haveMedicalDegree = !haveMedicalDegree;
|
||||||
});
|
});
|
||||||
context.read<RegisterConsultantCubit>().updateConsultantProfileModel(
|
context
|
||||||
context.read<RegisterConsultantCubit>().state.consultant.copyWith(
|
.read<RegisterConsultantCubit>()
|
||||||
hasMedicalEducation: haveMedicalDegree,
|
.updateConsultantProfileModel(
|
||||||
),
|
context
|
||||||
);
|
.read<RegisterConsultantCubit>()
|
||||||
|
.state
|
||||||
|
.consultant
|
||||||
|
.copyWith(hasMedicalEducation: haveMedicalDegree),
|
||||||
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
class BecomeConsultantScreen extends StatefulWidget {
|
class BecomeConsultantScreen extends StatefulWidget {
|
||||||
const BecomeConsultantScreen({super.key});
|
final String? slug;
|
||||||
|
|
||||||
|
const BecomeConsultantScreen({super.key, this.slug});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<BecomeConsultantScreen> createState() => _BecomeConsultantScreenState();
|
State<BecomeConsultantScreen> createState() => _BecomeConsultantScreenState();
|
||||||
@@ -17,7 +19,10 @@ class _BecomeConsultantScreenState extends State<BecomeConsultantScreen> {
|
|||||||
title: const Text('Стать консультантом'),
|
title: const Text('Стать консультантом'),
|
||||||
automaticallyImplyLeading: false,
|
automaticallyImplyLeading: false,
|
||||||
centerTitle: false,
|
centerTitle: false,
|
||||||
leading: IconButton(onPressed: () => GoRouter.of(context).pop(), icon: const Icon(Icons.close)),
|
leading: IconButton(
|
||||||
|
onPressed: () => GoRouter.of(context).pop(),
|
||||||
|
icon: const Icon(Icons.close),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
body: const Padding(
|
body: const Padding(
|
||||||
padding: EdgeInsets.all(16),
|
padding: EdgeInsets.all(16),
|
||||||
@@ -32,16 +37,27 @@ class _BecomeConsultantScreenState extends State<BecomeConsultantScreen> {
|
|||||||
|
|
||||||
InkWell _startRegistrationButton() {
|
InkWell _startRegistrationButton() {
|
||||||
return InkWell(
|
return InkWell(
|
||||||
onTap: () => GoRouter.of(context).pushReplacementNamed('stepperBecomeConsultantScreen', extra: {'slug': null}),
|
onTap: () => GoRouter.of(context).pushReplacementNamed(
|
||||||
|
'stepperBecomeConsultantScreen',
|
||||||
|
extra: {'slug': widget.slug, 'isNewRegistration': true},
|
||||||
|
),
|
||||||
borderRadius: BorderRadius.circular(20),
|
borderRadius: BorderRadius.circular(20),
|
||||||
child: Ink(
|
child: Ink(
|
||||||
height: 52,
|
height: 52,
|
||||||
width: MediaQuery.of(context).size.width - 32,
|
width: MediaQuery.of(context).size.width - 32,
|
||||||
decoration: BoxDecoration(borderRadius: BorderRadius.circular(20), color: appTheme.blueA400),
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
color: appTheme.blueA400,
|
||||||
|
),
|
||||||
child: const Center(
|
child: const Center(
|
||||||
child: Text(
|
child: Text(
|
||||||
'Начать регистрацию',
|
'Начать регистрацию',
|
||||||
style: TextStyle(color: Colors.white, height: 1, fontWeight: FontWeight.w600, fontSize: 18),
|
style: TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
height: 1,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
fontSize: 18,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -7,15 +7,18 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:eatvio_app/core/utils/date_time_utils.dart';
|
import 'package:eatvio_app/core/utils/date_time_utils.dart';
|
||||||
import 'package:flutter/scheduler.dart';
|
import 'package:flutter/scheduler.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
|
|
||||||
class CertificatesAndDiplomasScreen extends StatefulWidget {
|
class CertificatesAndDiplomasScreen extends StatefulWidget {
|
||||||
const CertificatesAndDiplomasScreen({super.key});
|
const CertificatesAndDiplomasScreen({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<CertificatesAndDiplomasScreen> createState() => _CertificatesAndDiplomasScreenState();
|
State<CertificatesAndDiplomasScreen> createState() =>
|
||||||
|
_CertificatesAndDiplomasScreenState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _CertificatesAndDiplomasScreenState extends State<CertificatesAndDiplomasScreen> {
|
class _CertificatesAndDiplomasScreenState
|
||||||
|
extends State<CertificatesAndDiplomasScreen> {
|
||||||
final scrollController = ScrollController();
|
final scrollController = ScrollController();
|
||||||
|
|
||||||
List<DiplomaEntity> diplomasList = [
|
List<DiplomaEntity> diplomasList = [
|
||||||
@@ -29,9 +32,15 @@ class _CertificatesAndDiplomasScreenState extends State<CertificatesAndDiplomasS
|
|||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
if (context.read<RegisterConsultantCubit>().state.consultant.certificates != null) {
|
if (context.read<RegisterConsultantCubit>().state.consultant.certificates !=
|
||||||
|
null) {
|
||||||
diplomasList = [
|
diplomasList = [
|
||||||
...context.read<RegisterConsultantCubit>().state.consultant.certificates!.map(
|
...context
|
||||||
|
.read<RegisterConsultantCubit>()
|
||||||
|
.state
|
||||||
|
.consultant
|
||||||
|
.certificates!
|
||||||
|
.map(
|
||||||
(e) => DiplomaEntity(
|
(e) => DiplomaEntity(
|
||||||
date: DateTime.tryParse(e.date),
|
date: DateTime.tryParse(e.date),
|
||||||
textEditingController: TextEditingController(text: e.title),
|
textEditingController: TextEditingController(text: e.title),
|
||||||
@@ -70,7 +79,8 @@ class _CertificatesAndDiplomasScreenState extends State<CertificatesAndDiplomasS
|
|||||||
ListView.separated(
|
ListView.separated(
|
||||||
shrinkWrap: true,
|
shrinkWrap: true,
|
||||||
physics: const NeverScrollableScrollPhysics(),
|
physics: const NeverScrollableScrollPhysics(),
|
||||||
itemBuilder: (context, index) => _diplomaInfo(diploma: diplomasList[index]),
|
itemBuilder: (context, index) =>
|
||||||
|
_diplomaInfo(diploma: diplomasList[index]),
|
||||||
separatorBuilder: (context, index) => Padding(
|
separatorBuilder: (context, index) => Padding(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||||
child: _divider(context),
|
child: _divider(context),
|
||||||
@@ -93,9 +103,7 @@ class _CertificatesAndDiplomasScreenState extends State<CertificatesAndDiplomasS
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Column _diplomaInfo({
|
Column _diplomaInfo({required DiplomaEntity diploma}) {
|
||||||
required DiplomaEntity diploma,
|
|
||||||
}) {
|
|
||||||
return Column(
|
return Column(
|
||||||
children: [
|
children: [
|
||||||
CustomTextFormField(
|
CustomTextFormField(
|
||||||
@@ -111,7 +119,9 @@ class _CertificatesAndDiplomasScreenState extends State<CertificatesAndDiplomasS
|
|||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
SizedBox(
|
SizedBox(
|
||||||
width: MediaQuery.of(context).size.width - (diploma.enableDeleteButton ? 92 : 32),
|
width:
|
||||||
|
MediaQuery.of(context).size.width -
|
||||||
|
(diploma.enableDeleteButton ? 92 : 32),
|
||||||
child: ListTile(
|
child: ListTile(
|
||||||
onTap: onTapCalendar(context, diploma),
|
onTap: onTapCalendar(context, diploma),
|
||||||
trailing: Icon(
|
trailing: Icon(
|
||||||
@@ -122,7 +132,9 @@ class _CertificatesAndDiplomasScreenState extends State<CertificatesAndDiplomasS
|
|||||||
title: Text(
|
title: Text(
|
||||||
diploma.date?.format('dd MMMM yyyy', 'ru') ?? 'Дата выдачи',
|
diploma.date?.format('dd MMMM yyyy', 'ru') ?? 'Дата выдачи',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: diploma.date == null ? Colors.grey.shade400 : Colors.black,
|
color: diploma.date == null
|
||||||
|
? Colors.grey.shade400
|
||||||
|
: Colors.black,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
tileColor: const Color(0xFFF6F6F6),
|
tileColor: const Color(0xFFF6F6F6),
|
||||||
@@ -148,9 +160,7 @@ class _CertificatesAndDiplomasScreenState extends State<CertificatesAndDiplomasS
|
|||||||
color: const Color(0xFFF6F6F6),
|
color: const Color(0xFFF6F6F6),
|
||||||
borderRadius: BorderRadius.circular(20),
|
borderRadius: BorderRadius.circular(20),
|
||||||
),
|
),
|
||||||
child: const Icon(
|
child: const Icon(Icons.delete_outline_rounded),
|
||||||
Icons.delete_outline_rounded,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -190,16 +200,14 @@ class _CertificatesAndDiplomasScreenState extends State<CertificatesAndDiplomasS
|
|||||||
bottom: MediaQuery.of(context).viewInsets.bottom,
|
bottom: MediaQuery.of(context).viewInsets.bottom,
|
||||||
),
|
),
|
||||||
color: CupertinoColors.systemBackground.resolveFrom(context),
|
color: CupertinoColors.systemBackground.resolveFrom(context),
|
||||||
child: SafeArea(
|
child: SafeArea(top: false, child: child),
|
||||||
top: false,
|
|
||||||
child: child,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void checkLast() {
|
void checkLast() {
|
||||||
if (diplomasList.last.textEditingController.text.isNotEmpty || diplomasList.last.date != null) {
|
if (diplomasList.last.textEditingController.text.isNotEmpty ||
|
||||||
|
diplomasList.last.date != null) {
|
||||||
setState(() {
|
setState(() {
|
||||||
diplomasList.add(
|
diplomasList.add(
|
||||||
DiplomaEntity(
|
DiplomaEntity(
|
||||||
@@ -230,12 +238,19 @@ class _CertificatesAndDiplomasScreenState extends State<CertificatesAndDiplomasS
|
|||||||
|
|
||||||
void _saveState() {
|
void _saveState() {
|
||||||
context.read<RegisterConsultantCubit>().updateConsultantProfileModel(
|
context.read<RegisterConsultantCubit>().updateConsultantProfileModel(
|
||||||
context.read<RegisterConsultantCubit>().state.consultant.copyWith(
|
context.read<RegisterConsultantCubit>().state.consultant.copyWith(
|
||||||
certificates: diplomasList.map((e) => CertificatesModel(title: e.textEditingController.text, date: e.date.toString())).toList().sublist(
|
certificates: diplomasList
|
||||||
0,
|
.map(
|
||||||
diplomasList.length - 1,
|
(e) => CertificatesModel(
|
||||||
),
|
title: e.textEditingController.text,
|
||||||
|
date: e.date == null
|
||||||
|
? ''
|
||||||
|
: DateFormat('yyyy-MM-dd').format(e.date!),
|
||||||
),
|
),
|
||||||
);
|
)
|
||||||
|
.toList()
|
||||||
|
.sublist(0, diplomasList.length - 1),
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
import 'package:eatvio_app/core/app_config.dart';
|
import 'package:eatvio_app/core/app_config.dart';
|
||||||
import 'package:eatvio_app/core/const.dart';
|
import 'package:eatvio_app/core/const.dart';
|
||||||
import 'package:eatvio_app/core/locator_service.dart';
|
import 'package:eatvio_app/core/locator_service.dart';
|
||||||
import 'package:eatvio_app/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_cubit.dart';
|
|
||||||
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
||||||
|
import 'package:eatvio_app/features/consultant/become_consultant/enums/alfa_scoring_status.dart';
|
||||||
|
import 'package:eatvio_app/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_cubit.dart';
|
||||||
|
import 'package:eatvio_app/features/consultant/become_consultant/presentation/widgets/scoring_status_widget.dart';
|
||||||
|
import 'package:flutter/gestures.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:url_launcher/url_launcher.dart';
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
@@ -15,10 +18,29 @@ class LastStepScreen extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _LastStepScreenState extends State<LastStepScreen> {
|
class _LastStepScreenState extends State<LastStepScreen> {
|
||||||
bool checkboxState = false;
|
static const _frdoUrl =
|
||||||
|
'https://obrnadzor.gov.ru/gosudarstvennye-uslugi-i-funkczii/7701537808-gosfunction/formirovanie-i-vedenie-federalnogo-reestra-svedenij-o-dokumentah-ob-obrazovanii-i-ili-o-kvalifikaczii-dokumentah-ob-obuchenii/';
|
||||||
|
|
||||||
|
late final TapGestureRecognizer _frdoTapRecognizer;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_frdoTapRecognizer = TapGestureRecognizer()
|
||||||
|
..onTap = () async => await launchUrl(Uri.parse(_frdoUrl));
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_frdoTapRecognizer.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final cubit = context.watch<RegisterConsultantCubit>();
|
||||||
|
final state = cubit.state;
|
||||||
|
|
||||||
return SingleChildScrollView(
|
return SingleChildScrollView(
|
||||||
padding: const EdgeInsets.only(bottom: 80, top: 16),
|
padding: const EdgeInsets.only(bottom: 80, top: 16),
|
||||||
child: Column(
|
child: Column(
|
||||||
@@ -27,34 +49,138 @@ class _LastStepScreenState extends State<LastStepScreen> {
|
|||||||
children: [
|
children: [
|
||||||
const Padding(
|
const Padding(
|
||||||
padding: EdgeInsets.symmetric(horizontal: 16),
|
padding: EdgeInsets.symmetric(horizontal: 16),
|
||||||
child: Text('Последний шаг', style: TextStyle(fontSize: 24, fontWeight: FontWeight.w700, height: 1.1)),
|
child: Text(
|
||||||
|
'Последний шаг',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 24,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
height: 1.1,
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
|
if (state.awaitingScoringOutcome ||
|
||||||
|
state.isScoringPolling ||
|
||||||
|
state.alfaScoringStatus !=
|
||||||
|
AlfaScoringStatus.none) ...[
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
const ScoringStatusWidget(),
|
||||||
|
],
|
||||||
|
if (state.requestModel?.canceledModeration != null) ...[
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
|
child: Container(
|
||||||
|
width: double.infinity,
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: const Color(0x33FFCC00),
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
state.requestModel!.canceledModeration!.title,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
height: 1.2,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (state.requestModel!.canceledModeration!.additional !=
|
||||||
|
null) ...[
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
for (final item
|
||||||
|
in state
|
||||||
|
.requestModel!
|
||||||
|
.canceledModeration!
|
||||||
|
.additional!) ...[
|
||||||
|
Text(
|
||||||
|
'• $item',
|
||||||
|
style: const TextStyle(fontSize: 14, height: 1.3),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
CheckboxListTile(
|
CheckboxListTile(
|
||||||
value: checkboxState,
|
value: state.acceptOffer,
|
||||||
title: Text(
|
title: Text(
|
||||||
hidePlans
|
hidePlans
|
||||||
? 'Я ознакомлен и принимаю политику конфиденциальности и защиту персональных данных'
|
? 'Я ознакомлен и принимаю политику конфиденциальности и защиту персональных данных'
|
||||||
: 'Я ознакомлен и принимаю договор публичной офёрты',
|
: 'Я ознакомлен и принимаю публичную оферту и политику конфиденциальности',
|
||||||
style: const TextStyle(fontSize: 18, height: 1),
|
style: const TextStyle(fontSize: 18, height: 1.2),
|
||||||
),
|
),
|
||||||
onChanged: (_) {
|
onChanged: (value) {
|
||||||
setState(() {
|
cubit.updateAgreements(acceptOffer: value ?? false);
|
||||||
checkboxState = !checkboxState;
|
|
||||||
});
|
|
||||||
context.read<RegisterConsultantCubit>().updateConsultantProfileModel(context.read<RegisterConsultantCubit>().state.consultant, checkboxState);
|
|
||||||
},
|
},
|
||||||
activeColor: appTheme.blueA400,
|
activeColor: appTheme.blueA400,
|
||||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16),
|
contentPadding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
|
controlAffinity: ListTileControlAffinity.leading,
|
||||||
|
),
|
||||||
|
if (!state.isUpdating) ...[
|
||||||
|
CheckboxListTile(
|
||||||
|
value: state.acceptFrdoAgreement,
|
||||||
|
title: Text.rich(
|
||||||
|
TextSpan(
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 18,
|
||||||
|
height: 1.2,
|
||||||
|
color: Color(0xFF323F4B),
|
||||||
|
),
|
||||||
|
children: [
|
||||||
|
const TextSpan(
|
||||||
|
text:
|
||||||
|
'Обязательное требование: Один из моих документов об образовании есть в реестре ',
|
||||||
|
),
|
||||||
|
TextSpan(
|
||||||
|
text: 'ФРДО',
|
||||||
|
style: TextStyle(
|
||||||
|
color: appTheme.blueA400,
|
||||||
|
decoration: TextDecoration.underline,
|
||||||
|
),
|
||||||
|
recognizer: _frdoTapRecognizer,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
onChanged: (value) {
|
||||||
|
cubit.updateAgreements(acceptFrdoAgreement: value ?? false);
|
||||||
|
},
|
||||||
|
activeColor: appTheme.blueA400,
|
||||||
|
contentPadding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
|
controlAffinity: ListTileControlAffinity.leading,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
|
||||||
|
child: _divider(context),
|
||||||
),
|
),
|
||||||
Padding(padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16), child: _divider(context)),
|
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 4),
|
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||||
child: TextButton(
|
child: TextButton(
|
||||||
onPressed: () async => await launchUrl(Uri.parse(hidePlans ? '${sl<AppConfig>().baseUrl}/docs/privacy.pdf' : '${sl<AppConfig>().baseUrl}/oferta_bs.pdf')),
|
onPressed: () async => await launchUrl(
|
||||||
|
Uri.parse(
|
||||||
|
hidePlans
|
||||||
|
? '${sl<AppConfig>().baseUrl}/docs/privacy.pdf'
|
||||||
|
: '${sl<AppConfig>().baseUrl}/oferta_bs.pdf',
|
||||||
|
),
|
||||||
|
),
|
||||||
child: Text(
|
child: Text(
|
||||||
hidePlans ? 'Политика конфиденциальности и пользовательское соглашение' : 'Договор офёрты',
|
hidePlans
|
||||||
style: const TextStyle(height: 1, fontSize: 16, color: Color(0xFF323F4B), decoration: TextDecoration.underline),
|
? 'Политика конфиденциальности и пользовательское соглашение'
|
||||||
|
: 'Договор оферты',
|
||||||
|
style: const TextStyle(
|
||||||
|
height: 1,
|
||||||
|
fontSize: 16,
|
||||||
|
color: Color(0xFF323F4B),
|
||||||
|
decoration: TextDecoration.underline,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -67,7 +193,10 @@ class _LastStepScreenState extends State<LastStepScreen> {
|
|||||||
return Container(
|
return Container(
|
||||||
height: 0.4,
|
height: 0.4,
|
||||||
width: MediaQuery.of(context).size.width - 32,
|
width: MediaQuery.of(context).size.width - 32,
|
||||||
decoration: BoxDecoration(color: Colors.black26, borderRadius: BorderRadius.circular(16)),
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.black26,
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,608 @@
|
|||||||
|
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/custom_text_form_field.dart';
|
||||||
|
import 'package:eatvio_app/features/consultant/become_consultant/models/sbp_bank_model.dart';
|
||||||
|
import 'package:eatvio_app/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_cubit.dart';
|
||||||
|
import 'package:eatvio_app/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_state.dart';
|
||||||
|
import 'package:eatvio_app/features/consultant/become_consultant/presentation/widgets/passport_field_with_error.dart';
|
||||||
|
import 'package:eatvio_app/features/consultant/become_consultant/presentation/widgets/passport_section_title.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
import 'package:flutter_typeahead/flutter_typeahead.dart';
|
||||||
|
import 'package:mask_text_input_formatter/mask_text_input_formatter.dart';
|
||||||
|
|
||||||
|
class PassportDataScreen extends StatefulWidget {
|
||||||
|
const PassportDataScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<PassportDataScreen> createState() => _PassportDataScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PassportDataScreenState extends State<PassportDataScreen> {
|
||||||
|
late final TextEditingController lastNameController;
|
||||||
|
late final TextEditingController firstNameController;
|
||||||
|
late final TextEditingController middleNameController;
|
||||||
|
late final TextEditingController innController;
|
||||||
|
late final TextEditingController snilsController;
|
||||||
|
late final TextEditingController seriesController;
|
||||||
|
late final TextEditingController numberController;
|
||||||
|
late final TextEditingController issueDateController;
|
||||||
|
late final TextEditingController issuerController;
|
||||||
|
late final TextEditingController issuerCodeController;
|
||||||
|
late final TextEditingController birthDateController;
|
||||||
|
late final TextEditingController birthPlaceController;
|
||||||
|
late final TextEditingController citizenshipController;
|
||||||
|
late final TextEditingController registrationAddressController;
|
||||||
|
late final TextEditingController residenceAddressController;
|
||||||
|
late final TextEditingController sbpPhoneController;
|
||||||
|
late final TextEditingController sbpBankController;
|
||||||
|
final sbpBankSuggestionsController = SuggestionsController<SbpBankModel>();
|
||||||
|
|
||||||
|
bool taxResidentUs = false;
|
||||||
|
bool foreignTaxResident = false;
|
||||||
|
String? selectedSbpBankMemberId;
|
||||||
|
String? selectedSbpBankName;
|
||||||
|
|
||||||
|
final snilsMask = MaskTextInputFormatter(
|
||||||
|
mask: '###-###-### ##',
|
||||||
|
filter: {'#': RegExp(r'[0-9]')},
|
||||||
|
type: MaskAutoCompletionType.lazy,
|
||||||
|
);
|
||||||
|
final dateMaskIssue = MaskTextInputFormatter(
|
||||||
|
mask: '##.##.####',
|
||||||
|
filter: {'#': RegExp(r'[0-9]')},
|
||||||
|
type: MaskAutoCompletionType.lazy,
|
||||||
|
);
|
||||||
|
final dateMaskBirth = MaskTextInputFormatter(
|
||||||
|
mask: '##.##.####',
|
||||||
|
filter: {'#': RegExp(r'[0-9]')},
|
||||||
|
type: MaskAutoCompletionType.lazy,
|
||||||
|
);
|
||||||
|
final issuerCodeMask = MaskTextInputFormatter(
|
||||||
|
mask: '###-###',
|
||||||
|
filter: {'#': RegExp(r'[0-9]')},
|
||||||
|
type: MaskAutoCompletionType.lazy,
|
||||||
|
);
|
||||||
|
final phoneMask = MaskTextInputFormatter(
|
||||||
|
mask: '+7 (###) ### ## ##',
|
||||||
|
filter: {'#': RegExp(r'[0-9]')},
|
||||||
|
type: MaskAutoCompletionType.lazy,
|
||||||
|
);
|
||||||
|
final _alfaPersonNameFormatter = FilteringTextInputFormatter.allow(
|
||||||
|
RegExp(r'[a-zA-Zа-яА-ЯёЁ ,.-]'),
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
final consultant = context.read<RegisterConsultantCubit>().state.consultant;
|
||||||
|
lastNameController = TextEditingController(text: consultant.lastName ?? '');
|
||||||
|
firstNameController = TextEditingController(
|
||||||
|
text: consultant.firstName ?? '',
|
||||||
|
);
|
||||||
|
middleNameController = TextEditingController(
|
||||||
|
text: consultant.middleName ?? '',
|
||||||
|
);
|
||||||
|
innController = TextEditingController(text: consultant.inn ?? '');
|
||||||
|
snilsController = TextEditingController(text: consultant.snils ?? '');
|
||||||
|
seriesController = TextEditingController(
|
||||||
|
text: consultant.passportSeries ?? '',
|
||||||
|
);
|
||||||
|
numberController = TextEditingController(
|
||||||
|
text: consultant.passportNumber ?? '',
|
||||||
|
);
|
||||||
|
issueDateController = TextEditingController(
|
||||||
|
text: _apiDateToUi(consultant.passportIssueDate),
|
||||||
|
);
|
||||||
|
issuerController = TextEditingController(
|
||||||
|
text: consultant.passportIssuer ?? '',
|
||||||
|
);
|
||||||
|
issuerCodeController = TextEditingController(
|
||||||
|
text: consultant.passportIssuerCode ?? '',
|
||||||
|
);
|
||||||
|
birthDateController = TextEditingController(
|
||||||
|
text: _apiDateToUi(consultant.birthDate),
|
||||||
|
);
|
||||||
|
birthPlaceController = TextEditingController(
|
||||||
|
text: consultant.birthPlace ?? '',
|
||||||
|
);
|
||||||
|
citizenshipController = TextEditingController(text: consultant.citizenship);
|
||||||
|
registrationAddressController = TextEditingController(
|
||||||
|
text: consultant.registrationAddress ?? '',
|
||||||
|
);
|
||||||
|
residenceAddressController = TextEditingController(
|
||||||
|
text: consultant.residenceAddress ?? '',
|
||||||
|
);
|
||||||
|
sbpPhoneController = TextEditingController(text: consultant.sbpPhone ?? '');
|
||||||
|
selectedSbpBankMemberId = consultant.sbpBankMemberId;
|
||||||
|
final selectedBank = context
|
||||||
|
.read<RegisterConsultantCubit>()
|
||||||
|
.findSbpBankById(consultant.sbpBankMemberId);
|
||||||
|
selectedSbpBankName = selectedBank?.bankName;
|
||||||
|
sbpBankController = TextEditingController(text: selectedSbpBankName ?? '');
|
||||||
|
taxResidentUs = consultant.taxResidentUs ?? false;
|
||||||
|
foreignTaxResident = consultant.foreignTaxResident ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _hydrateSbpBankNameIfNeeded() {
|
||||||
|
if (selectedSbpBankMemberId == null || selectedSbpBankMemberId!.isEmpty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (sbpBankController.text.trim().isNotEmpty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final selectedBank = context
|
||||||
|
.read<RegisterConsultantCubit>()
|
||||||
|
.findSbpBankById(selectedSbpBankMemberId);
|
||||||
|
if (selectedBank == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
selectedSbpBankName = selectedBank.bankName;
|
||||||
|
sbpBankController.text = selectedBank.bankName;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
lastNameController.dispose();
|
||||||
|
firstNameController.dispose();
|
||||||
|
middleNameController.dispose();
|
||||||
|
innController.dispose();
|
||||||
|
snilsController.dispose();
|
||||||
|
seriesController.dispose();
|
||||||
|
numberController.dispose();
|
||||||
|
issueDateController.dispose();
|
||||||
|
issuerController.dispose();
|
||||||
|
issuerCodeController.dispose();
|
||||||
|
birthDateController.dispose();
|
||||||
|
birthPlaceController.dispose();
|
||||||
|
citizenshipController.dispose();
|
||||||
|
registrationAddressController.dispose();
|
||||||
|
residenceAddressController.dispose();
|
||||||
|
sbpPhoneController.dispose();
|
||||||
|
sbpBankController.dispose();
|
||||||
|
sbpBankSuggestionsController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
String _apiDateToUi(String? apiDate) {
|
||||||
|
if (apiDate == null || apiDate.isEmpty) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
final parts = apiDate.split('-');
|
||||||
|
if (parts.length != 3) {
|
||||||
|
return apiDate;
|
||||||
|
}
|
||||||
|
return '${parts[2]}.${parts[1]}.${parts[0]}';
|
||||||
|
}
|
||||||
|
|
||||||
|
String? _uiDateToApi(String uiDate) {
|
||||||
|
final digits = uiDate.replaceAll(RegExp(r'\D'), '');
|
||||||
|
if (digits.length != 8) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return '${digits.substring(4, 8)}-${digits.substring(2, 4)}-${digits.substring(0, 2)}';
|
||||||
|
}
|
||||||
|
|
||||||
|
String? _uiPhoneToApi(String uiPhone) {
|
||||||
|
final digits = uiPhone.replaceAll(RegExp(r'\D'), '');
|
||||||
|
if (digits.isEmpty) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (digits.length == 11 && digits.startsWith('7')) {
|
||||||
|
return '+$digits';
|
||||||
|
}
|
||||||
|
if (digits.length == 10) {
|
||||||
|
return '+7$digits';
|
||||||
|
}
|
||||||
|
return uiPhone.trim().isEmpty ? null : uiPhone.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _syncToCubit() {
|
||||||
|
final cubit = context.read<RegisterConsultantCubit>();
|
||||||
|
final current = cubit.state.consultant;
|
||||||
|
cubit.updateConsultantProfileModel(
|
||||||
|
current.copyWith(
|
||||||
|
legalType: RegisterConsultantCubit.individualPersonLegalType,
|
||||||
|
lastName: lastNameController.text.trim().isEmpty
|
||||||
|
? null
|
||||||
|
: lastNameController.text.trim(),
|
||||||
|
firstName: firstNameController.text.trim().isEmpty
|
||||||
|
? null
|
||||||
|
: firstNameController.text.trim(),
|
||||||
|
middleName: middleNameController.text.trim().isEmpty
|
||||||
|
? null
|
||||||
|
: middleNameController.text.trim(),
|
||||||
|
inn: innController.text.trim().isEmpty
|
||||||
|
? null
|
||||||
|
: innController.text.trim(),
|
||||||
|
snils: snilsController.text.trim().isEmpty
|
||||||
|
? null
|
||||||
|
: snilsController.text.trim(),
|
||||||
|
passportSeries: seriesController.text.trim().isEmpty
|
||||||
|
? null
|
||||||
|
: seriesController.text.trim(),
|
||||||
|
passportNumber: numberController.text.trim().isEmpty
|
||||||
|
? null
|
||||||
|
: numberController.text.trim(),
|
||||||
|
passportIssueDate: _uiDateToApi(issueDateController.text),
|
||||||
|
passportIssuer: issuerController.text.trim().isEmpty
|
||||||
|
? null
|
||||||
|
: issuerController.text.trim(),
|
||||||
|
passportIssuerCode: issuerCodeController.text.trim().isEmpty
|
||||||
|
? null
|
||||||
|
: issuerCodeController.text.trim(),
|
||||||
|
birthDate: _uiDateToApi(birthDateController.text),
|
||||||
|
birthPlace: birthPlaceController.text.trim().isEmpty
|
||||||
|
? null
|
||||||
|
: birthPlaceController.text.trim(),
|
||||||
|
citizenship: citizenshipController.text.trim().isEmpty
|
||||||
|
? 'RU'
|
||||||
|
: citizenshipController.text.trim().toUpperCase(),
|
||||||
|
registrationAddress: registrationAddressController.text.trim().isEmpty
|
||||||
|
? null
|
||||||
|
: registrationAddressController.text.trim(),
|
||||||
|
residenceAddress: residenceAddressController.text.trim().isEmpty
|
||||||
|
? null
|
||||||
|
: residenceAddressController.text.trim(),
|
||||||
|
taxResidentUs: taxResidentUs,
|
||||||
|
foreignTaxResident: foreignTaxResident,
|
||||||
|
sbpPhone: _uiPhoneToApi(sbpPhoneController.text),
|
||||||
|
sbpBankMemberId: selectedSbpBankMemberId,
|
||||||
|
),
|
||||||
|
cubit.state.acceptOffer,
|
||||||
|
cubit.state.acceptFrdoAgreement,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _selectSbpBank(SbpBankModel bank) {
|
||||||
|
setState(() {
|
||||||
|
selectedSbpBankMemberId = bank.bankMemberId;
|
||||||
|
selectedSbpBankName = bank.bankName;
|
||||||
|
sbpBankController.text = bank.bankName;
|
||||||
|
});
|
||||||
|
_syncToCubit();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _clearSbpBankIfNeeded(String value) {
|
||||||
|
if (selectedSbpBankMemberId != null && value != selectedSbpBankName) {
|
||||||
|
setState(() {
|
||||||
|
selectedSbpBankMemberId = null;
|
||||||
|
selectedSbpBankName = null;
|
||||||
|
});
|
||||||
|
_syncToCubit();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return BlocListener<RegisterConsultantCubit, RegisterConsultantState>(
|
||||||
|
listenWhen: (previous, current) => previous.sbpBanks != current.sbpBanks,
|
||||||
|
listener: (context, state) => _hydrateSbpBankNameIfNeeded(),
|
||||||
|
child: BlocBuilder<RegisterConsultantCubit, RegisterConsultantState>(
|
||||||
|
buildWhen: (previous, current) =>
|
||||||
|
previous.fieldErrors != current.fieldErrors,
|
||||||
|
builder: (context, state) {
|
||||||
|
final errors = state.fieldErrors;
|
||||||
|
return SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.only(
|
||||||
|
bottom: 80,
|
||||||
|
right: 16,
|
||||||
|
left: 16,
|
||||||
|
top: 16,
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const PassportScreenTitle(title: 'Паспортные данные'),
|
||||||
|
const PassportSectionTitle(
|
||||||
|
title: 'Персональные данные (как в паспорте)',
|
||||||
|
),
|
||||||
|
PassportFieldWithError(
|
||||||
|
error: errors['last_name'],
|
||||||
|
child: CustomTextFormField(
|
||||||
|
hintText: 'Фамилия',
|
||||||
|
controller: lastNameController,
|
||||||
|
maxLength: 50,
|
||||||
|
inputFormatters: [_alfaPersonNameFormatter],
|
||||||
|
hasError: errors.containsKey('last_name'),
|
||||||
|
onChanged: (_) => _syncToCubit(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
PassportFieldWithError(
|
||||||
|
error: errors['first_name'],
|
||||||
|
child: CustomTextFormField(
|
||||||
|
hintText: 'Имя',
|
||||||
|
controller: firstNameController,
|
||||||
|
maxLength: 50,
|
||||||
|
inputFormatters: [_alfaPersonNameFormatter],
|
||||||
|
hasError: errors.containsKey('first_name'),
|
||||||
|
onChanged: (_) => _syncToCubit(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
PassportFieldWithError(
|
||||||
|
error: errors['middle_name'],
|
||||||
|
child: CustomTextFormField(
|
||||||
|
hintText: 'Отчество',
|
||||||
|
controller: middleNameController,
|
||||||
|
maxLength: 50,
|
||||||
|
inputFormatters: [_alfaPersonNameFormatter],
|
||||||
|
hasError: errors.containsKey('middle_name'),
|
||||||
|
onChanged: (_) => _syncToCubit(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const PassportSectionTitle(title: 'Идентификация'),
|
||||||
|
PassportFieldWithError(
|
||||||
|
error: errors['inn'],
|
||||||
|
child: CustomTextFormField(
|
||||||
|
hintText: 'ИНН (12 цифр)',
|
||||||
|
controller: innController,
|
||||||
|
keyboardType: TextInputType.number,
|
||||||
|
maxLength: 12,
|
||||||
|
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
|
||||||
|
hasError: errors.containsKey('inn'),
|
||||||
|
onChanged: (_) => _syncToCubit(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
PassportFieldWithError(
|
||||||
|
error: errors['snils'],
|
||||||
|
child: CustomTextFormField(
|
||||||
|
hintText: 'СНИЛС',
|
||||||
|
controller: snilsController,
|
||||||
|
keyboardType: TextInputType.number,
|
||||||
|
inputFormatters: [snilsMask],
|
||||||
|
hasError: errors.containsKey('snils'),
|
||||||
|
onChanged: (_) => _syncToCubit(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
PassportFieldWithError(
|
||||||
|
error: errors['birth_date'],
|
||||||
|
child: CustomTextFormField(
|
||||||
|
hintText: 'Дата рождения (ДД.ММ.ГГГГ)',
|
||||||
|
controller: birthDateController,
|
||||||
|
keyboardType: TextInputType.number,
|
||||||
|
inputFormatters: [dateMaskBirth],
|
||||||
|
hasError: errors.containsKey('birth_date'),
|
||||||
|
onChanged: (_) => _syncToCubit(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
PassportFieldWithError(
|
||||||
|
error: errors['birth_place'],
|
||||||
|
child: CustomTextFormField(
|
||||||
|
hintText: 'Место рождения',
|
||||||
|
controller: birthPlaceController,
|
||||||
|
hasError: errors.containsKey('birth_place'),
|
||||||
|
onChanged: (_) => _syncToCubit(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
PassportFieldWithError(
|
||||||
|
error: errors['citizenship'],
|
||||||
|
child: CustomTextFormField(
|
||||||
|
hintText: 'Гражданство (ISO, например RU)',
|
||||||
|
controller: citizenshipController,
|
||||||
|
maxLength: 2,
|
||||||
|
hasError: errors.containsKey('citizenship'),
|
||||||
|
onChanged: (_) => _syncToCubit(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
PassportFieldWithError(
|
||||||
|
error: errors['registration_address'],
|
||||||
|
child: CustomTextFormField(
|
||||||
|
hintText: 'Адрес регистрации',
|
||||||
|
controller: registrationAddressController,
|
||||||
|
hasError: errors.containsKey('registration_address'),
|
||||||
|
onChanged: (_) => _syncToCubit(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
PassportFieldWithError(
|
||||||
|
error: errors['residence_address'],
|
||||||
|
child: CustomTextFormField(
|
||||||
|
hintText: 'Адрес проживания',
|
||||||
|
controller: residenceAddressController,
|
||||||
|
hasError: errors.containsKey('residence_address'),
|
||||||
|
onChanged: (_) => _syncToCubit(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
CheckboxListTile(
|
||||||
|
value: taxResidentUs,
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
controlAffinity: ListTileControlAffinity.leading,
|
||||||
|
activeColor: appTheme.blueA400,
|
||||||
|
title: Text(
|
||||||
|
'Я являюсь налоговым резидентом США',
|
||||||
|
style: TextStyles.textMMediumAuto.copyWith(
|
||||||
|
color: appTheme.black,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
onChanged: (value) {
|
||||||
|
setState(() => taxResidentUs = value ?? false);
|
||||||
|
_syncToCubit();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
CheckboxListTile(
|
||||||
|
value: foreignTaxResident,
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
controlAffinity: ListTileControlAffinity.leading,
|
||||||
|
activeColor: appTheme.blueA400,
|
||||||
|
title: Text(
|
||||||
|
'Я являюсь иностранным налоговым резидентом',
|
||||||
|
style: TextStyles.textMMediumAuto.copyWith(
|
||||||
|
color: appTheme.black,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
onChanged: (value) {
|
||||||
|
setState(() => foreignTaxResident = value ?? false);
|
||||||
|
_syncToCubit();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const PassportSectionTitle(title: 'Паспорт'),
|
||||||
|
Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: PassportFieldWithError(
|
||||||
|
error: errors['passport_series'],
|
||||||
|
child: CustomTextFormField(
|
||||||
|
hintText: 'Серия',
|
||||||
|
controller: seriesController,
|
||||||
|
keyboardType: TextInputType.number,
|
||||||
|
maxLength: 4,
|
||||||
|
inputFormatters: [
|
||||||
|
FilteringTextInputFormatter.digitsOnly,
|
||||||
|
],
|
||||||
|
hasError: errors.containsKey('passport_series'),
|
||||||
|
onChanged: (_) => _syncToCubit(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: PassportFieldWithError(
|
||||||
|
error: errors['passport_number'],
|
||||||
|
child: CustomTextFormField(
|
||||||
|
hintText: 'Номер',
|
||||||
|
controller: numberController,
|
||||||
|
keyboardType: TextInputType.number,
|
||||||
|
maxLength: 6,
|
||||||
|
inputFormatters: [
|
||||||
|
FilteringTextInputFormatter.digitsOnly,
|
||||||
|
],
|
||||||
|
hasError: errors.containsKey('passport_number'),
|
||||||
|
onChanged: (_) => _syncToCubit(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
PassportFieldWithError(
|
||||||
|
error: errors['passport_issue_date'],
|
||||||
|
child: CustomTextFormField(
|
||||||
|
hintText: 'Дата выдачи (ДД.ММ.ГГГГ)',
|
||||||
|
controller: issueDateController,
|
||||||
|
keyboardType: TextInputType.number,
|
||||||
|
inputFormatters: [dateMaskIssue],
|
||||||
|
hasError: errors.containsKey('passport_issue_date'),
|
||||||
|
onChanged: (_) => _syncToCubit(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
PassportFieldWithError(
|
||||||
|
error: errors['passport_issuer_code'],
|
||||||
|
child: CustomTextFormField(
|
||||||
|
hintText: 'Код подразделения',
|
||||||
|
controller: issuerCodeController,
|
||||||
|
keyboardType: TextInputType.number,
|
||||||
|
inputFormatters: [issuerCodeMask],
|
||||||
|
hasError: errors.containsKey('passport_issuer_code'),
|
||||||
|
onChanged: (_) => _syncToCubit(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
PassportFieldWithError(
|
||||||
|
error: errors['passport_issuer'],
|
||||||
|
child: CustomTextFormField(
|
||||||
|
hintText: 'Кем выдан',
|
||||||
|
controller: issuerController,
|
||||||
|
hasError: errors.containsKey('passport_issuer'),
|
||||||
|
onChanged: (_) => _syncToCubit(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const PassportSectionTitle(title: 'Реквизиты СБП'),
|
||||||
|
PassportFieldWithError(
|
||||||
|
error: errors['sbp_phone'],
|
||||||
|
child: CustomTextFormField(
|
||||||
|
hintText: 'Телефон для выплат через СБП',
|
||||||
|
controller: sbpPhoneController,
|
||||||
|
keyboardType: TextInputType.phone,
|
||||||
|
inputFormatters: [phoneMask],
|
||||||
|
hasError: errors.containsKey('sbp_phone'),
|
||||||
|
onChanged: (_) => _syncToCubit(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
PassportFieldWithError(
|
||||||
|
error: errors['sbp_bank_member_id'],
|
||||||
|
child: TypeAheadField<SbpBankModel>(
|
||||||
|
debounceDuration: const Duration(milliseconds: 250),
|
||||||
|
controller: sbpBankController,
|
||||||
|
suggestionsController: sbpBankSuggestionsController,
|
||||||
|
builder: (context, controller, focusNode) =>
|
||||||
|
CustomTextFormField(
|
||||||
|
controller: controller,
|
||||||
|
focusNode: focusNode,
|
||||||
|
hintText: 'Банк получатель (поиск)',
|
||||||
|
hasError: errors.containsKey('sbp_bank_member_id'),
|
||||||
|
onChanged: _clearSbpBankIfNeeded,
|
||||||
|
),
|
||||||
|
suggestionsCallback: (query) async {
|
||||||
|
return context
|
||||||
|
.read<RegisterConsultantCubit>()
|
||||||
|
.searchSbpBanks(query);
|
||||||
|
},
|
||||||
|
itemBuilder: (context, bank) {
|
||||||
|
return ListTile(
|
||||||
|
dense: true,
|
||||||
|
title: Text(
|
||||||
|
bank.bankName,
|
||||||
|
style: TextStyles.textMMedium.copyWith(
|
||||||
|
color: appTheme.black900,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
subtitle:
|
||||||
|
bank.shortName == null || bank.shortName!.isEmpty
|
||||||
|
? null
|
||||||
|
: Text(
|
||||||
|
bank.shortName!,
|
||||||
|
style: TextStyles.textSMedium.copyWith(
|
||||||
|
color: appTheme.grey7B,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
decorationBuilder: (context, child) => Material(
|
||||||
|
type: MaterialType.card,
|
||||||
|
elevation: 4,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
color: appTheme.whiteA700,
|
||||||
|
child: child,
|
||||||
|
),
|
||||||
|
emptyBuilder: (context) => Padding(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
child: Text(
|
||||||
|
'Ничего не найдено',
|
||||||
|
style: TextStyles.textSMedium.copyWith(
|
||||||
|
color: appTheme.grey7B,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
onSelected: _selectSbpBank,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Divider(height: 0.4, thickness: 0.4, color: appTheme.greyD9),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
'Данные используются для проверки и скоринга. Заполните поля в соответствии с паспортом РФ.',
|
||||||
|
style: TextStyles.textSMedium.copyWith(
|
||||||
|
color: appTheme.blueA400,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -29,32 +29,48 @@ class _PhotoOrScansScreenState extends State<PhotoOrScansScreen> {
|
|||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
const Text('Загрузите фото или сканы дипломов и сертификатов', style: TextStyle(fontSize: 24, fontWeight: FontWeight.w700, height: 1.1)),
|
const Text(
|
||||||
|
'Загрузите фото или сканы дипломов и сертификатов',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 24,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
height: 1.1,
|
||||||
|
),
|
||||||
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
BlocBuilder<RegisterConsultantCubit, RegisterConsultantState>(
|
BlocBuilder<RegisterConsultantCubit, RegisterConsultantState>(
|
||||||
builder: (context, state) {
|
builder: (context, state) {
|
||||||
final photos = state.certPhotos.where((photo) => !state.deletedPhotoIds.contains(photo.id)).toList();
|
final photos = state.certPhotos
|
||||||
|
.where((photo) => !state.deletedPhotoIds.contains(photo.id))
|
||||||
|
.toList();
|
||||||
final addedPhotos = state.newAddedPhotos;
|
final addedPhotos = state.newAddedPhotos;
|
||||||
final totalPhotosCount = photos.length + addedPhotos.length;
|
final totalPhotosCount = photos.length + addedPhotos.length;
|
||||||
return Column(
|
return Column(
|
||||||
children: [
|
children: [
|
||||||
if (isUploading) ...[const LinearProgressIndicator(), const SizedBox(height: 16)],
|
if (isUploading) ...[
|
||||||
|
const LinearProgressIndicator(),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
],
|
||||||
GridView.builder(
|
GridView.builder(
|
||||||
shrinkWrap: true,
|
shrinkWrap: true,
|
||||||
physics: const NeverScrollableScrollPhysics(),
|
physics: const NeverScrollableScrollPhysics(),
|
||||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
gridDelegate:
|
||||||
crossAxisCount: 3,
|
const SliverGridDelegateWithFixedCrossAxisCount(
|
||||||
childAspectRatio: 3 / 4,
|
crossAxisCount: 3,
|
||||||
crossAxisSpacing: 8,
|
childAspectRatio: 3 / 4,
|
||||||
mainAxisSpacing: 8,
|
crossAxisSpacing: 8,
|
||||||
),
|
mainAxisSpacing: 8,
|
||||||
|
),
|
||||||
itemBuilder: (BuildContext context, int index) {
|
itemBuilder: (BuildContext context, int index) {
|
||||||
if (index == totalPhotosCount) {
|
if (index == totalPhotosCount) {
|
||||||
return InkWell(
|
return InkWell(
|
||||||
borderRadius: BorderRadius.circular(20),
|
borderRadius: BorderRadius.circular(20),
|
||||||
onTap: () => _pickAndUploadPhotos(),
|
onTap: () => _pickAndUploadPhotos(),
|
||||||
child: Ink(
|
child: Ink(
|
||||||
decoration: BoxDecoration(color: const Color(0xFFF6F6F6), borderRadius: BorderRadius.circular(20)),
|
decoration: BoxDecoration(
|
||||||
|
color: const Color(0xFFF6F6F6),
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
),
|
||||||
child: Icon(Icons.add, color: appTheme.blueA400),
|
child: Icon(Icons.add, color: appTheme.blueA400),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -72,7 +88,8 @@ class _PhotoOrScansScreenState extends State<PhotoOrScansScreen> {
|
|||||||
return _LocalCertPhotoCard(
|
return _LocalCertPhotoCard(
|
||||||
photo: localPhoto,
|
photo: localPhoto,
|
||||||
allPhotos: addedPhotos,
|
allPhotos: addedPhotos,
|
||||||
onRemove: () => _removePhoto(path: localPhoto.uri.path),
|
onRemove: () =>
|
||||||
|
_removePhoto(path: localPhoto.uri.path),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -99,7 +116,9 @@ class _PhotoOrScansScreenState extends State<PhotoOrScansScreen> {
|
|||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
// ignore: use_build_context_synchronously
|
// ignore: use_build_context_synchronously
|
||||||
await context.read<RegisterConsultantCubit>().addCertPhotos(newImages.map((file) => File(file.path)).toList());
|
await context.read<RegisterConsultantCubit>().addCertPhotos(
|
||||||
|
newImages.map((file) => File(file.path)).toList(),
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() {
|
setState(() {
|
||||||
@@ -119,7 +138,11 @@ class _CertPhotoCard extends StatelessWidget {
|
|||||||
final List<ConsultantCertPhotoModel> allPhotos;
|
final List<ConsultantCertPhotoModel> allPhotos;
|
||||||
final VoidCallback onRemove;
|
final VoidCallback onRemove;
|
||||||
|
|
||||||
const _CertPhotoCard({required this.photo, required this.allPhotos, required this.onRemove});
|
const _CertPhotoCard({
|
||||||
|
required this.photo,
|
||||||
|
required this.allPhotos,
|
||||||
|
required this.onRemove,
|
||||||
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -131,7 +154,11 @@ class _CertPhotoCard extends StatelessWidget {
|
|||||||
onTap: () {
|
onTap: () {
|
||||||
context.pushTransparentRoute(
|
context.pushTransparentRoute(
|
||||||
rootNavigator: true,
|
rootNavigator: true,
|
||||||
PhotoViewScreen(imageUrls: allPhotos.map((e) => e.url).toList(), isLocal: false, index: allPhotos.indexOf(photo)),
|
PhotoViewScreen(
|
||||||
|
imageUrls: allPhotos.map((e) => e.url).toList(),
|
||||||
|
isLocal: false,
|
||||||
|
index: allPhotos.indexOf(photo),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
borderRadius: BorderRadius.circular(20),
|
borderRadius: BorderRadius.circular(20),
|
||||||
@@ -141,7 +168,10 @@ class _CertPhotoCard extends StatelessWidget {
|
|||||||
Ink(
|
Ink(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
borderRadius: const BorderRadius.all(Radius.circular(20)),
|
borderRadius: const BorderRadius.all(Radius.circular(20)),
|
||||||
image: DecorationImage(fit: BoxFit.cover, image: NetworkImage(photo.url)),
|
image: DecorationImage(
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
image: NetworkImage(photo.url),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Positioned(
|
Positioned(
|
||||||
@@ -152,8 +182,15 @@ class _CertPhotoCard extends StatelessWidget {
|
|||||||
customBorder: const CircleBorder(),
|
customBorder: const CircleBorder(),
|
||||||
child: Ink(
|
child: Ink(
|
||||||
padding: const EdgeInsets.all(4),
|
padding: const EdgeInsets.all(4),
|
||||||
decoration: const BoxDecoration(color: Color(0xFFF1F2F6), shape: BoxShape.circle),
|
decoration: const BoxDecoration(
|
||||||
child: const Icon(size: 20, Icons.close, color: Colors.black),
|
color: Color(0xFFF1F2F6),
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
child: const Icon(
|
||||||
|
size: 20,
|
||||||
|
Icons.close,
|
||||||
|
color: Colors.black,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -170,7 +207,11 @@ class _LocalCertPhotoCard extends StatelessWidget {
|
|||||||
final List<File> allPhotos;
|
final List<File> allPhotos;
|
||||||
final VoidCallback onRemove;
|
final VoidCallback onRemove;
|
||||||
|
|
||||||
const _LocalCertPhotoCard({required this.photo, required this.allPhotos, required this.onRemove});
|
const _LocalCertPhotoCard({
|
||||||
|
required this.photo,
|
||||||
|
required this.allPhotos,
|
||||||
|
required this.onRemove,
|
||||||
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -210,8 +251,15 @@ class _LocalCertPhotoCard extends StatelessWidget {
|
|||||||
customBorder: const CircleBorder(),
|
customBorder: const CircleBorder(),
|
||||||
child: Ink(
|
child: Ink(
|
||||||
padding: const EdgeInsets.all(4),
|
padding: const EdgeInsets.all(4),
|
||||||
decoration: const BoxDecoration(color: Color(0xFFF1F2F6), shape: BoxShape.circle),
|
decoration: const BoxDecoration(
|
||||||
child: const Icon(size: 20, Icons.close, color: Colors.black),
|
color: Color(0xFFF1F2F6),
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
child: const Icon(
|
||||||
|
size: 20,
|
||||||
|
Icons.close,
|
||||||
|
color: Colors.black,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
||||||
|
import 'package:eatvio_app/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_cubit.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
|
||||||
class RegistrationAsScreen extends StatefulWidget {
|
class RegistrationAsScreen extends StatefulWidget {
|
||||||
const RegistrationAsScreen({super.key});
|
const RegistrationAsScreen({super.key});
|
||||||
@@ -11,7 +13,26 @@ class RegistrationAsScreen extends StatefulWidget {
|
|||||||
enum RegistrationAsType { physical, selfBusy, iP, ooo }
|
enum RegistrationAsType { physical, selfBusy, iP, ooo }
|
||||||
|
|
||||||
class _RegistrationAsScreenState extends State<RegistrationAsScreen> {
|
class _RegistrationAsScreenState extends State<RegistrationAsScreen> {
|
||||||
RegistrationAsType registrationAsType = RegistrationAsType.physical;
|
static const RegistrationAsType registrationAsType =
|
||||||
|
RegistrationAsType.physical;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (!mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final cubit = context.read<RegisterConsultantCubit>();
|
||||||
|
cubit.updateConsultantProfileModel(
|
||||||
|
cubit.state.consultant.copyWith(
|
||||||
|
legalType: RegisterConsultantCubit.individualPersonLegalType,
|
||||||
|
),
|
||||||
|
cubit.state.acceptOffer,
|
||||||
|
cubit.state.acceptFrdoAgreement,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -23,7 +44,14 @@ class _RegistrationAsScreenState extends State<RegistrationAsScreen> {
|
|||||||
children: [
|
children: [
|
||||||
const Padding(
|
const Padding(
|
||||||
padding: EdgeInsets.symmetric(horizontal: 16),
|
padding: EdgeInsets.symmetric(horizontal: 16),
|
||||||
child: Text('Регистрация в качестве', style: TextStyle(fontSize: 24, fontWeight: FontWeight.w700, height: 1.1)),
|
child: Text(
|
||||||
|
'Регистрация в качестве',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 24,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
height: 1.1,
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Column(
|
Column(
|
||||||
@@ -36,16 +64,15 @@ class _RegistrationAsScreenState extends State<RegistrationAsScreen> {
|
|||||||
groupValue: registrationAsType,
|
groupValue: registrationAsType,
|
||||||
activeColor: appTheme.blueA400,
|
activeColor: appTheme.blueA400,
|
||||||
// ignore: deprecated_member_use
|
// ignore: deprecated_member_use
|
||||||
onChanged: (value) {
|
onChanged: (_) {},
|
||||||
setState(() {
|
|
||||||
registrationAsType = value!;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
const Expanded(
|
const Expanded(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: EdgeInsets.symmetric(horizontal: 8),
|
padding: EdgeInsets.symmetric(horizontal: 8),
|
||||||
child: Text('Физического лица', style: TextStyle(fontSize: 18, height: 1)),
|
child: Text(
|
||||||
|
'Физического лица',
|
||||||
|
style: TextStyle(fontSize: 18, height: 1),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -58,16 +85,19 @@ class _RegistrationAsScreenState extends State<RegistrationAsScreen> {
|
|||||||
groupValue: registrationAsType,
|
groupValue: registrationAsType,
|
||||||
activeColor: appTheme.blueA400,
|
activeColor: appTheme.blueA400,
|
||||||
// ignore: deprecated_member_use
|
// ignore: deprecated_member_use
|
||||||
onChanged: (value) {
|
onChanged: null,
|
||||||
setState(() {
|
|
||||||
registrationAsType = value!;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
const Expanded(
|
const Expanded(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: EdgeInsets.symmetric(horizontal: 8),
|
padding: EdgeInsets.symmetric(horizontal: 8),
|
||||||
child: Text('Самозанятого', style: TextStyle(fontSize: 18, height: 1)),
|
child: Text(
|
||||||
|
'Самозанятого',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 18,
|
||||||
|
height: 1,
|
||||||
|
color: Colors.black38,
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -80,16 +110,19 @@ class _RegistrationAsScreenState extends State<RegistrationAsScreen> {
|
|||||||
groupValue: registrationAsType,
|
groupValue: registrationAsType,
|
||||||
activeColor: appTheme.blueA400,
|
activeColor: appTheme.blueA400,
|
||||||
// ignore: deprecated_member_use
|
// ignore: deprecated_member_use
|
||||||
onChanged: (value) {
|
onChanged: null,
|
||||||
setState(() {
|
|
||||||
registrationAsType = value!;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
const Expanded(
|
const Expanded(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: EdgeInsets.symmetric(horizontal: 8),
|
padding: EdgeInsets.symmetric(horizontal: 8),
|
||||||
child: Text('Индивидуального предпринимателя', style: TextStyle(fontSize: 18, height: 1)),
|
child: Text(
|
||||||
|
'Индивидуального предпринимателя',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 18,
|
||||||
|
height: 1,
|
||||||
|
color: Colors.black38,
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -102,26 +135,34 @@ class _RegistrationAsScreenState extends State<RegistrationAsScreen> {
|
|||||||
groupValue: registrationAsType,
|
groupValue: registrationAsType,
|
||||||
activeColor: appTheme.blueA400,
|
activeColor: appTheme.blueA400,
|
||||||
// ignore: deprecated_member_use
|
// ignore: deprecated_member_use
|
||||||
onChanged: (value) {
|
onChanged: null,
|
||||||
setState(() {
|
|
||||||
registrationAsType = value!;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
const Expanded(
|
const Expanded(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: EdgeInsets.symmetric(horizontal: 8),
|
padding: EdgeInsets.symmetric(horizontal: 8),
|
||||||
child: Text('Общества с ограниченной ответственностью', style: TextStyle(fontSize: 18, height: 1)),
|
child: Text(
|
||||||
|
'Общества с ограниченной ответственностью',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 18,
|
||||||
|
height: 1,
|
||||||
|
color: Colors.black38,
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
Padding(padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16), child: _divider(context)),
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
|
||||||
|
child: _divider(context),
|
||||||
|
),
|
||||||
const Padding(
|
const Padding(
|
||||||
padding: EdgeInsets.symmetric(horizontal: 16),
|
padding: EdgeInsets.symmetric(horizontal: 16),
|
||||||
child: Text('* На данный момент регистрация доступна только в качестве физического лица. Остальные варианты скоро появятся'),
|
child: Text(
|
||||||
|
'* На данный момент регистрация доступна только в качестве физического лица. Остальные варианты скоро появятся',
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -132,7 +173,10 @@ class _RegistrationAsScreenState extends State<RegistrationAsScreen> {
|
|||||||
return Container(
|
return Container(
|
||||||
height: 0.4,
|
height: 0.4,
|
||||||
width: MediaQuery.of(context).size.width - 32,
|
width: MediaQuery.of(context).size.width - 32,
|
||||||
decoration: BoxDecoration(color: Colors.black26, borderRadius: BorderRadius.circular(16)),
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.black26,
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,32 +19,47 @@ class _SocialNetworksScreenState extends State<SocialNetworksScreen> {
|
|||||||
|
|
||||||
var maskFormatter = MaskTextInputFormatter(
|
var maskFormatter = MaskTextInputFormatter(
|
||||||
mask: 'https://########################################',
|
mask: 'https://########################################',
|
||||||
filter: {
|
filter: {'#': RegExp(r"[a-zA-Z. ]+")},
|
||||||
'#': RegExp(r"[a-zA-Z. ]+"),
|
|
||||||
},
|
|
||||||
type: MaskAutoCompletionType.lazy,
|
type: MaskAutoCompletionType.lazy,
|
||||||
);
|
);
|
||||||
var maskTgFormatter = MaskTextInputFormatter(
|
var maskTgFormatter = MaskTextInputFormatter(
|
||||||
mask: 'https://tg.me/########################################',
|
mask: 'https://tg.me/########################################',
|
||||||
filter: {
|
filter: {'#': RegExp(r"[a-zA-Z. ]+")},
|
||||||
'#': RegExp(r"[a-zA-Z. ]+"),
|
|
||||||
},
|
|
||||||
type: MaskAutoCompletionType.lazy,
|
type: MaskAutoCompletionType.lazy,
|
||||||
);
|
);
|
||||||
var maskVkFormatter = MaskTextInputFormatter(
|
var maskVkFormatter = MaskTextInputFormatter(
|
||||||
mask: 'https://vk.com/########################################',
|
mask: 'https://vk.com/########################################',
|
||||||
filter: {
|
filter: {'#': RegExp(r"[a-zA-Z. ]+")},
|
||||||
'#': RegExp(r"[a-zA-Z. ]+"),
|
|
||||||
},
|
|
||||||
type: MaskAutoCompletionType.lazy,
|
type: MaskAutoCompletionType.lazy,
|
||||||
);
|
);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
vkController.text = context.read<RegisterConsultantCubit>().state.consultant.socialLinks!.vkLink ?? '';
|
vkController.text =
|
||||||
telegramController.text = context.read<RegisterConsultantCubit>().state.consultant.socialLinks!.tgLink ?? '';
|
context
|
||||||
webSiteController.text = context.read<RegisterConsultantCubit>().state.consultant.socialLinks!.personalSiteLink ?? '';
|
.read<RegisterConsultantCubit>()
|
||||||
|
.state
|
||||||
|
.consultant
|
||||||
|
.socialLinks!
|
||||||
|
.vkLink ??
|
||||||
|
'';
|
||||||
|
telegramController.text =
|
||||||
|
context
|
||||||
|
.read<RegisterConsultantCubit>()
|
||||||
|
.state
|
||||||
|
.consultant
|
||||||
|
.socialLinks!
|
||||||
|
.tgLink ??
|
||||||
|
'';
|
||||||
|
webSiteController.text =
|
||||||
|
context
|
||||||
|
.read<RegisterConsultantCubit>()
|
||||||
|
.state
|
||||||
|
.consultant
|
||||||
|
.socialLinks!
|
||||||
|
.personalSiteLink ??
|
||||||
|
'';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -75,9 +90,7 @@ class _SocialNetworksScreenState extends State<SocialNetworksScreen> {
|
|||||||
color: const Color(0xFFF6F6F6),
|
color: const Color(0xFFF6F6F6),
|
||||||
borderRadius: BorderRadius.circular(20),
|
borderRadius: BorderRadius.circular(20),
|
||||||
),
|
),
|
||||||
child: SvgPicture.asset(
|
child: SvgPicture.asset('assets/icons/vk.svg'),
|
||||||
'assets/icons/vk.svg',
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
SizedBox(
|
SizedBox(
|
||||||
@@ -105,9 +118,7 @@ class _SocialNetworksScreenState extends State<SocialNetworksScreen> {
|
|||||||
color: const Color(0xFFF6F6F6),
|
color: const Color(0xFFF6F6F6),
|
||||||
borderRadius: BorderRadius.circular(20),
|
borderRadius: BorderRadius.circular(20),
|
||||||
),
|
),
|
||||||
child: SvgPicture.asset(
|
child: SvgPicture.asset('assets/icons/telegram.svg'),
|
||||||
'assets/icons/telegram.svg',
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
SizedBox(
|
SizedBox(
|
||||||
@@ -135,9 +146,7 @@ class _SocialNetworksScreenState extends State<SocialNetworksScreen> {
|
|||||||
color: const Color(0xFFF6F6F6),
|
color: const Color(0xFFF6F6F6),
|
||||||
borderRadius: BorderRadius.circular(20),
|
borderRadius: BorderRadius.circular(20),
|
||||||
),
|
),
|
||||||
child: SvgPicture.asset(
|
child: SvgPicture.asset('assets/icons/web.svg'),
|
||||||
'assets/icons/web.svg',
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
SizedBox(
|
SizedBox(
|
||||||
@@ -164,25 +173,45 @@ class _SocialNetworksScreenState extends State<SocialNetworksScreen> {
|
|||||||
String? personalSiteLink,
|
String? personalSiteLink,
|
||||||
}) {
|
}) {
|
||||||
context.read<RegisterConsultantCubit>().updateConsultantProfileModel(
|
context.read<RegisterConsultantCubit>().updateConsultantProfileModel(
|
||||||
context.read<RegisterConsultantCubit>().state.consultant.copyWith(
|
context.read<RegisterConsultantCubit>().state.consultant.copyWith(
|
||||||
socialLinks: context.read<RegisterConsultantCubit>().state.consultant.socialLinks?.copyWith(
|
socialLinks: context
|
||||||
tgLink: tgLink == null
|
.read<RegisterConsultantCubit>()
|
||||||
? context.read<RegisterConsultantCubit>().state.consultant.socialLinks?.tgLink
|
.state
|
||||||
: tgLink.isEmpty
|
.consultant
|
||||||
? null
|
.socialLinks
|
||||||
: tgLink,
|
?.copyWith(
|
||||||
vkLink: vkLink == null
|
tgLink: tgLink == null
|
||||||
? context.read<RegisterConsultantCubit>().state.consultant.socialLinks?.vkLink
|
? context
|
||||||
: vkLink.isEmpty
|
.read<RegisterConsultantCubit>()
|
||||||
? null
|
.state
|
||||||
: vkLink,
|
.consultant
|
||||||
personalSiteLink: personalSiteLink == null
|
.socialLinks
|
||||||
? context.read<RegisterConsultantCubit>().state.consultant.socialLinks?.personalSiteLink
|
?.tgLink
|
||||||
: personalSiteLink.isEmpty
|
: tgLink.isEmpty
|
||||||
? null
|
? null
|
||||||
: personalSiteLink,
|
: tgLink,
|
||||||
),
|
vkLink: vkLink == null
|
||||||
),
|
? context
|
||||||
);
|
.read<RegisterConsultantCubit>()
|
||||||
|
.state
|
||||||
|
.consultant
|
||||||
|
.socialLinks
|
||||||
|
?.vkLink
|
||||||
|
: vkLink.isEmpty
|
||||||
|
? null
|
||||||
|
: vkLink,
|
||||||
|
personalSiteLink: personalSiteLink == null
|
||||||
|
? context
|
||||||
|
.read<RegisterConsultantCubit>()
|
||||||
|
.state
|
||||||
|
.consultant
|
||||||
|
.socialLinks
|
||||||
|
?.personalSiteLink
|
||||||
|
: personalSiteLink.isEmpty
|
||||||
|
? null
|
||||||
|
: personalSiteLink,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import 'package:eatvio_app/core/utils/image_constant.dart';
|
import 'package:eatvio_app/core/utils/image_constant.dart';
|
||||||
import 'package:eatvio_app/core/widgets/data_error_widget.dart';
|
import 'package:eatvio_app/core/widgets/data_error_widget.dart';
|
||||||
import 'package:eatvio_app/core/widgets/loader.dart';
|
import 'package:eatvio_app/core/widgets/loader.dart';
|
||||||
|
import 'package:eatvio_app/features/consultant/become_consultant/enums/alfa_scoring_status.dart';
|
||||||
import 'package:eatvio_app/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_cubit.dart';
|
import 'package:eatvio_app/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_cubit.dart';
|
||||||
import 'package:eatvio_app/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_state.dart';
|
import 'package:eatvio_app/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_state.dart';
|
||||||
import 'package:eatvio_app/features/consultant/become_consultant/presentation/pages/additional_settings_screen.dart';
|
import 'package:eatvio_app/features/consultant/become_consultant/presentation/pages/additional_settings_screen.dart';
|
||||||
import 'package:eatvio_app/features/consultant/become_consultant/presentation/pages/certificates_and_diplomas_screen.dart';
|
import 'package:eatvio_app/features/consultant/become_consultant/presentation/pages/certificates_and_diplomas_screen.dart';
|
||||||
import 'package:eatvio_app/features/consultant/become_consultant/presentation/pages/last_step_screen.dart';
|
import 'package:eatvio_app/features/consultant/become_consultant/presentation/pages/last_step_screen.dart';
|
||||||
|
import 'package:eatvio_app/features/consultant/become_consultant/presentation/pages/passport_data_screen.dart';
|
||||||
import 'package:eatvio_app/features/consultant/become_consultant/presentation/pages/photo_or_scans_screen.dart';
|
import 'package:eatvio_app/features/consultant/become_consultant/presentation/pages/photo_or_scans_screen.dart';
|
||||||
import 'package:eatvio_app/features/consultant/become_consultant/presentation/pages/registration_as_screen.dart';
|
import 'package:eatvio_app/features/consultant/become_consultant/presentation/pages/registration_as_screen.dart';
|
||||||
import 'package:eatvio_app/features/consultant/become_consultant/presentation/pages/social_networks_screen.dart';
|
import 'package:eatvio_app/features/consultant/become_consultant/presentation/pages/social_networks_screen.dart';
|
||||||
@@ -21,67 +23,196 @@ import 'package:lottie/lottie.dart';
|
|||||||
class StepperBecomeConsultantScreen extends StatefulWidget {
|
class StepperBecomeConsultantScreen extends StatefulWidget {
|
||||||
final String? slug;
|
final String? slug;
|
||||||
final bool? isCanceled;
|
final bool? isCanceled;
|
||||||
const StepperBecomeConsultantScreen({super.key, this.slug, this.isCanceled});
|
final bool isNewRegistration;
|
||||||
|
const StepperBecomeConsultantScreen({
|
||||||
|
super.key,
|
||||||
|
this.slug,
|
||||||
|
this.isCanceled,
|
||||||
|
this.isNewRegistration = false,
|
||||||
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<StepperBecomeConsultantScreen> createState() => _StepperBecomeConsultantScreenState();
|
State<StepperBecomeConsultantScreen> createState() =>
|
||||||
|
_StepperBecomeConsultantScreenState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _StepperBecomeConsultantScreenState extends State<StepperBecomeConsultantScreen> {
|
class _StepperBecomeConsultantScreenState
|
||||||
double progressValue = 1 / 6;
|
extends State<StepperBecomeConsultantScreen> {
|
||||||
|
double progressValue = 0;
|
||||||
int step = 1;
|
int step = 1;
|
||||||
final pageController = PageController();
|
final pageController = PageController();
|
||||||
late final RegisterConsultantCubit registrationConsultantCubit;
|
late final RegisterConsultantCubit registrationConsultantCubit;
|
||||||
bool isLoading = false;
|
bool isLoading = false;
|
||||||
|
bool _successSheetShown = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
if (widget.slug == null) {
|
if (widget.isNewRegistration || widget.slug == null) {
|
||||||
registrationConsultantCubit = sl<RegisterConsultantCubit>()..disableLoading();
|
registrationConsultantCubit = sl<RegisterConsultantCubit>()
|
||||||
|
..disableLoading(slug: widget.slug);
|
||||||
} else {
|
} else {
|
||||||
registrationConsultantCubit = sl<RegisterConsultantCubit>()..getMyRequestStatus(widget.slug!);
|
registrationConsultantCubit = sl<RegisterConsultantCubit>()
|
||||||
|
..getMyRequestStatus(widget.slug!);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static const int totalSteps = 7;
|
||||||
|
|
||||||
|
List<Widget> _pages() {
|
||||||
|
return const [
|
||||||
|
CertificatesAndDiplomasScreen(),
|
||||||
|
PhotoOrScansScreen(),
|
||||||
|
AdditionalSettingsScreen(),
|
||||||
|
SocialNetworksScreen(),
|
||||||
|
RegistrationAsScreen(),
|
||||||
|
PassportDataScreen(),
|
||||||
|
LastStepScreen(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _showSuccessSheet(BuildContext context) async {
|
||||||
|
if (_successSheetShown) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_successSheetShown = true;
|
||||||
|
await showModalBottomSheet(
|
||||||
|
useRootNavigator: true,
|
||||||
|
showDragHandle: true,
|
||||||
|
context: context,
|
||||||
|
builder: (context) => Container(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
width: MediaQuery.of(context).size.width,
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const Text(
|
||||||
|
'Спасибо!\nВаши данные отправлены на модерацию',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 20,
|
||||||
|
height: 1,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Lottie.asset(
|
||||||
|
'assets/lottie_animations/success.json',
|
||||||
|
width: 160,
|
||||||
|
height: 160,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
const Text(
|
||||||
|
'В течении трех рабочих дней Вам придет уведомление о смене статуса',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
height: 1,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
InkWell(
|
||||||
|
onTap: () {
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
GoRouter.of(context).pop();
|
||||||
|
},
|
||||||
|
child: Ink(
|
||||||
|
width: MediaQuery.of(context).size.width - 32,
|
||||||
|
height: 52,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
color: appTheme.blueA400,
|
||||||
|
),
|
||||||
|
child: const Center(
|
||||||
|
child: Text(
|
||||||
|
'Закрыть',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return BlocProvider<RegisterConsultantCubit>(
|
return BlocProvider<RegisterConsultantCubit>(
|
||||||
create: (context) => registrationConsultantCubit,
|
create: (context) => registrationConsultantCubit,
|
||||||
child: BlocBuilder<RegisterConsultantCubit, RegisterConsultantState>(
|
child: BlocListener<RegisterConsultantCubit, RegisterConsultantState>(
|
||||||
builder: (context, state) {
|
listenWhen: (previous, current) =>
|
||||||
if (state.loading) {
|
previous.fieldErrors.isEmpty &&
|
||||||
return const Scaffold(body: Loader());
|
current.fieldErrors.isNotEmpty &&
|
||||||
}
|
current.error == null,
|
||||||
if (state.error != null) {
|
listener: (context, state) {
|
||||||
|
pageController.jumpToPage(5);
|
||||||
|
},
|
||||||
|
child: BlocConsumer<RegisterConsultantCubit, RegisterConsultantState>(
|
||||||
|
listenWhen: (previous, current) =>
|
||||||
|
previous.awaitingScoringOutcome &&
|
||||||
|
!current.awaitingScoringOutcome &&
|
||||||
|
current.alfaScoringStatus.isComplete,
|
||||||
|
listener: (context, state) {
|
||||||
|
_showSuccessSheet(context);
|
||||||
|
},
|
||||||
|
builder: (context, state) {
|
||||||
|
if (state.loading) {
|
||||||
|
return const Scaffold(body: Loader());
|
||||||
|
}
|
||||||
|
if (state.error != null &&
|
||||||
|
state.requestModel == null &&
|
||||||
|
widget.slug != null &&
|
||||||
|
state.alfaScoringStatus == AlfaScoringStatus.none) {
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
centerTitle: true,
|
||||||
|
leading: BackButton(
|
||||||
|
onPressed: () {
|
||||||
|
if (context.canPop()) {
|
||||||
|
GoRouter.of(context).pop();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
GoRouter.of(context).go('/feed-screen');
|
||||||
|
},
|
||||||
|
),
|
||||||
|
title: Text(
|
||||||
|
'',
|
||||||
|
maxLines: 2,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
softWrap: true,
|
||||||
|
style: TextStyles.headingXLSemibold.copyWith(
|
||||||
|
color: appTheme.black,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
body: DataErrorWidget(
|
||||||
|
onReload: () => context
|
||||||
|
.read<RegisterConsultantCubit>()
|
||||||
|
.reloadData(widget.slug),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (progressValue == 0) {
|
||||||
|
progressValue = 1 / totalSteps;
|
||||||
|
}
|
||||||
|
|
||||||
|
final submitDisabled =
|
||||||
|
isLoading ||
|
||||||
|
state.isScoringPolling ||
|
||||||
|
state.scoringRetryInProgress;
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
floatingActionButtonLocation:
|
||||||
centerTitle: true,
|
FloatingActionButtonLocation.centerFloat,
|
||||||
leading: BackButton(
|
|
||||||
onPressed: () {
|
|
||||||
if (context.canPop()) {
|
|
||||||
GoRouter.of(context).pop();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
GoRouter.of(context).go('/feed-screen');
|
|
||||||
},
|
|
||||||
),
|
|
||||||
title: Text(
|
|
||||||
'',
|
|
||||||
maxLines: 2,
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
softWrap: true,
|
|
||||||
style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
body: DataErrorWidget(
|
|
||||||
onReload: () => context.read<RegisterConsultantCubit>().reloadData(widget.slug),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return Scaffold(
|
|
||||||
floatingActionButtonLocation: FloatingActionButtonLocation.centerFloat,
|
|
||||||
floatingActionButton: Row(
|
floatingActionButton: Row(
|
||||||
children: [
|
children: [
|
||||||
Container(
|
Container(
|
||||||
@@ -93,33 +224,51 @@ class _StepperBecomeConsultantScreenState extends State<StepperBecomeConsultantS
|
|||||||
highlightElevation: 0,
|
highlightElevation: 0,
|
||||||
focusElevation: 0,
|
focusElevation: 0,
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
),
|
||||||
backgroundColor: const Color(0xFFEAF2FD),
|
backgroundColor: const Color(0xFFEAF2FD),
|
||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
setState(() {
|
setState(() {
|
||||||
pageController.previousPage(duration: const Duration(milliseconds: 350), curve: Curves.easeInOut);
|
pageController.previousPage(
|
||||||
|
duration: const Duration(milliseconds: 350),
|
||||||
|
curve: Curves.easeInOut,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
label: Icon(Icons.arrow_back_ios_new_rounded, color: appTheme.blueA400),
|
label: Icon(
|
||||||
|
Icons.arrow_back_ios_new_rounded,
|
||||||
|
color: appTheme.blueA400,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (widget.isCanceled == true)
|
if (widget.isCanceled == true && step != totalSteps)
|
||||||
InkWell(
|
InkWell(
|
||||||
onTap: () {
|
onTap: () {
|
||||||
|
final canceledModeration =
|
||||||
|
state.requestModel?.canceledModeration;
|
||||||
|
if (canceledModeration == null) return;
|
||||||
showDialog(
|
showDialog(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (context) => AlertDialog(
|
builder: (context) => AlertDialog(
|
||||||
title: Text(state.requestModel!.canceledModeration!.title),
|
title: Text(canceledModeration.title),
|
||||||
content: Column(
|
content: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
for (int i = 0; i < state.requestModel!.canceledModeration!.additional!.length; i++) ...[
|
if (canceledModeration.additional != null)
|
||||||
Text('• ${state.requestModel!.canceledModeration!.additional![i]}'),
|
for (final item
|
||||||
],
|
in canceledModeration.additional!) ...[
|
||||||
|
Text('• $item'),
|
||||||
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
actions: [TextButton(onPressed: () => Navigator.pop(context), child: const Text('OK'))],
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
child: const Text('OK'),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -127,18 +276,26 @@ class _StepperBecomeConsultantScreenState extends State<StepperBecomeConsultantS
|
|||||||
child: Ink(
|
child: Ink(
|
||||||
height: 48,
|
height: 48,
|
||||||
width: MediaQuery.of(context).size.width * 0.6,
|
width: MediaQuery.of(context).size.width * 0.6,
|
||||||
decoration: BoxDecoration(borderRadius: BorderRadius.circular(20), color: const Color(0x33FFCC00)),
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
color: const Color(0x33FFCC00),
|
||||||
|
),
|
||||||
child: const Center(
|
child: const Center(
|
||||||
child: Text(
|
child: Text(
|
||||||
'Комментарий',
|
'Комментарий',
|
||||||
style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: Color(0xFF323F4B), height: 1),
|
style: TextStyle(
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: Color(0xFF323F4B),
|
||||||
|
height: 1,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Container(
|
Container(
|
||||||
height: 48,
|
height: 48,
|
||||||
width: widget.isCanceled == true
|
width: widget.isCanceled == true && step != totalSteps
|
||||||
? MediaQuery.of(context).size.width * 0.2
|
? MediaQuery.of(context).size.width * 0.2
|
||||||
: MediaQuery.of(context).size.width * 0.8,
|
: MediaQuery.of(context).size.width * 0.8,
|
||||||
padding: const EdgeInsets.only(left: 4, right: 16),
|
padding: const EdgeInsets.only(left: 4, right: 16),
|
||||||
@@ -147,85 +304,77 @@ class _StepperBecomeConsultantScreenState extends State<StepperBecomeConsultantS
|
|||||||
highlightElevation: 0,
|
highlightElevation: 0,
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
focusElevation: 0,
|
focusElevation: 0,
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
shape: RoundedRectangleBorder(
|
||||||
backgroundColor: isLoading
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
),
|
||||||
|
backgroundColor: submitDisabled
|
||||||
? Colors.grey
|
? Colors.grey
|
||||||
: widget.isCanceled == true
|
: widget.isCanceled == true && step != totalSteps
|
||||||
? const Color(0xFFEAF2FD)
|
? const Color(0xFFEAF2FD)
|
||||||
: appTheme.blueA400,
|
: appTheme.blueA400,
|
||||||
onPressed: isLoading
|
onPressed: submitDisabled
|
||||||
? null
|
? null
|
||||||
: () async {
|
: () async {
|
||||||
if (step == 6) {
|
if (step == totalSteps) {
|
||||||
if (widget.isCanceled == true) {
|
final passportErrors =
|
||||||
|
registrationConsultantCubit
|
||||||
|
.validatePassportFields();
|
||||||
|
if (passportErrors.isNotEmpty) {
|
||||||
|
pageController.jumpToPage(5);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (registrationConsultantCubit.state.acceptOffer) {
|
if (registrationConsultantCubit
|
||||||
|
.hasAcceptedRequiredAgreements) {
|
||||||
setState(() {
|
setState(() {
|
||||||
isLoading = true;
|
isLoading = true;
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
await registrationConsultantCubit.registerConsultant();
|
await registrationConsultantCubit
|
||||||
|
.registerConsultant();
|
||||||
if (!context.mounted) return;
|
if (!context.mounted) return;
|
||||||
if (registrationConsultantCubit.state.error != null) {
|
if (registrationConsultantCubit
|
||||||
return;
|
.state
|
||||||
}
|
.error !=
|
||||||
await showModalBottomSheet(
|
null) {
|
||||||
useRootNavigator: true,
|
showDialog(
|
||||||
showDragHandle: true,
|
context: context,
|
||||||
context: context,
|
builder: (context) => AlertDialog(
|
||||||
builder: (context) => Container(
|
actionsPadding:
|
||||||
padding: const EdgeInsets.all(16),
|
const EdgeInsets.symmetric(
|
||||||
width: MediaQuery.of(context).size.width,
|
horizontal: 4,
|
||||||
child: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
const Text(
|
|
||||||
'Спасибо!\nВаши данные отправлены на модерацию',
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
style: TextStyle(fontSize: 20, height: 1, fontWeight: FontWeight.w600),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
Lottie.asset('assets/lottie_animations/success.json', width: 160, height: 160),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
const Text(
|
|
||||||
'В течении трех рабочих дней Вам придет уведомление о смене статуса',
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
style: TextStyle(fontSize: 16, height: 1, fontWeight: FontWeight.w500),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
InkWell(
|
|
||||||
onTap: () {
|
|
||||||
Navigator.of(context).pop();
|
|
||||||
GoRouter.of(context).pop();
|
|
||||||
},
|
|
||||||
child: Ink(
|
|
||||||
width: MediaQuery.of(context).size.width - 32,
|
|
||||||
height: 52,
|
|
||||||
decoration: BoxDecoration(borderRadius: BorderRadius.circular(20), color: appTheme.blueA400),
|
|
||||||
child: const Center(
|
|
||||||
child: Text(
|
|
||||||
'Закрыть',
|
|
||||||
style: TextStyle(color: Colors.white, fontSize: 18, fontWeight: FontWeight.w600),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
|
content: Text(
|
||||||
|
registrationConsultantCubit
|
||||||
|
.state
|
||||||
|
.error!,
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
child: const Text('Ок'),
|
||||||
|
onPressed: () =>
|
||||||
|
Navigator.of(context).pop(),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
);
|
||||||
);
|
return;
|
||||||
|
}
|
||||||
|
// Success sheet is shown by BlocListener when scoring completes.
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!context.mounted) return;
|
if (!context.mounted) return;
|
||||||
showDialog<void>(
|
showDialog<void>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (context) => AlertDialog(
|
builder: (context) => AlertDialog(
|
||||||
actionsPadding: const EdgeInsets.symmetric(horizontal: 4),
|
actionsPadding:
|
||||||
|
const EdgeInsets.symmetric(
|
||||||
|
horizontal: 4,
|
||||||
|
),
|
||||||
content: Text(e.toString()),
|
content: Text(e.toString()),
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
TextButton(
|
||||||
child: const Text('Ок'),
|
child: const Text('Ок'),
|
||||||
onPressed: () => Navigator.of(context).pop(),
|
onPressed: () =>
|
||||||
|
Navigator.of(context).pop(),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -239,8 +388,15 @@ class _StepperBecomeConsultantScreenState extends State<StepperBecomeConsultantS
|
|||||||
showDialog(
|
showDialog(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (context) => AlertDialog(
|
builder: (context) => AlertDialog(
|
||||||
actionsPadding: const EdgeInsets.symmetric(horizontal: 4),
|
actionsPadding:
|
||||||
content: const Text('Примите договор публичной офёрты'),
|
const EdgeInsets.symmetric(
|
||||||
|
horizontal: 4,
|
||||||
|
),
|
||||||
|
content: Text(
|
||||||
|
state.isUpdating
|
||||||
|
? 'Примите договор публичной оферты'
|
||||||
|
: 'Примите оферту и согласие ФРДО',
|
||||||
|
),
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
TextButton(
|
||||||
child: const Text('Ок'),
|
child: const Text('Ок'),
|
||||||
@@ -255,15 +411,26 @@ class _StepperBecomeConsultantScreenState extends State<StepperBecomeConsultantS
|
|||||||
} else {
|
} else {
|
||||||
FocusManager.instance.primaryFocus?.unfocus();
|
FocusManager.instance.primaryFocus?.unfocus();
|
||||||
setState(() {
|
setState(() {
|
||||||
pageController.nextPage(duration: const Duration(milliseconds: 350), curve: Curves.easeInOut);
|
pageController.nextPage(
|
||||||
|
duration: const Duration(milliseconds: 350),
|
||||||
|
curve: Curves.easeInOut,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
label: isLoading
|
label: submitDisabled && step == totalSteps
|
||||||
? const CircularProgressIndicator()
|
? const CircularProgressIndicator()
|
||||||
: widget.isCanceled == true
|
: widget.isCanceled == true && step != totalSteps
|
||||||
? Icon(Icons.arrow_forward_ios_outlined, color: appTheme.blueA400)
|
? Icon(
|
||||||
: Text('Продолжить', style: theme.textTheme.titleSmall!.copyWith(color: Colors.white)),
|
Icons.arrow_forward_ios_outlined,
|
||||||
|
color: appTheme.blueA400,
|
||||||
|
)
|
||||||
|
: Text(
|
||||||
|
step == totalSteps ? 'Отправить' : 'Продолжить',
|
||||||
|
style: theme.textTheme.titleSmall!.copyWith(
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -271,11 +438,23 @@ class _StepperBecomeConsultantScreenState extends State<StepperBecomeConsultantS
|
|||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
automaticallyImplyLeading: false,
|
automaticallyImplyLeading: false,
|
||||||
leading: Builder(
|
leading: Builder(
|
||||||
builder: (context) => IconButton(icon: const Icon(Icons.close_rounded), onPressed: () => GoRouter.of(context).pop()),
|
builder: (context) => IconButton(
|
||||||
|
icon: const Icon(Icons.close_rounded),
|
||||||
|
onPressed: () => GoRouter.of(context).pop(),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
centerTitle: true,
|
centerTitle: true,
|
||||||
title: SvgPicture.asset(ImageConstant.imgLogoEatvio, height: 48, fit: BoxFit.contain),
|
title: SvgPicture.asset(
|
||||||
actions: [Padding(padding: const EdgeInsets.only(right: 16), child: Text('шаг $step из 6'))],
|
ImageConstant.imgLogoEatvio,
|
||||||
|
height: 48,
|
||||||
|
fit: BoxFit.contain,
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(right: 16),
|
||||||
|
child: Text('шаг $step из $totalSteps'),
|
||||||
|
),
|
||||||
|
],
|
||||||
bottom: PreferredSize(
|
bottom: PreferredSize(
|
||||||
preferredSize: Size(MediaQuery.of(context).size.width, 32),
|
preferredSize: Size(MediaQuery.of(context).size.width, 32),
|
||||||
child: Padding(
|
child: Padding(
|
||||||
@@ -283,13 +462,18 @@ class _StepperBecomeConsultantScreenState extends State<StepperBecomeConsultantS
|
|||||||
child: TweenAnimationBuilder<double>(
|
child: TweenAnimationBuilder<double>(
|
||||||
duration: const Duration(milliseconds: 350),
|
duration: const Duration(milliseconds: 350),
|
||||||
curve: Curves.easeInOut,
|
curve: Curves.easeInOut,
|
||||||
tween: Tween<double>(begin: 1 / 6, end: progressValue),
|
tween: Tween<double>(
|
||||||
|
begin: 1 / totalSteps,
|
||||||
|
end: progressValue,
|
||||||
|
),
|
||||||
builder: (context, value, _) => LinearProgressIndicator(
|
builder: (context, value, _) => LinearProgressIndicator(
|
||||||
value: value,
|
value: value,
|
||||||
borderRadius: BorderRadius.circular(16),
|
borderRadius: BorderRadius.circular(16),
|
||||||
minHeight: 8,
|
minHeight: 8,
|
||||||
backgroundColor: Colors.grey.shade100,
|
backgroundColor: Colors.grey.shade100,
|
||||||
valueColor: AlwaysStoppedAnimation<Color>(appTheme.blueA400),
|
valueColor: AlwaysStoppedAnimation<Color>(
|
||||||
|
appTheme.blueA400,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -301,20 +485,14 @@ class _StepperBecomeConsultantScreenState extends State<StepperBecomeConsultantS
|
|||||||
onPageChanged: (index) {
|
onPageChanged: (index) {
|
||||||
setState(() {
|
setState(() {
|
||||||
step = index + 1;
|
step = index + 1;
|
||||||
progressValue = step / 6;
|
progressValue = step / totalSteps;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
children: const [
|
children: _pages(),
|
||||||
CertificatesAndDiplomasScreen(),
|
|
||||||
PhotoOrScansScreen(),
|
|
||||||
AdditionalSettingsScreen(),
|
|
||||||
SocialNetworksScreen(),
|
|
||||||
RegistrationAsScreen(),
|
|
||||||
LastStepScreen(),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import 'package:eatvio_app/features/create_diet/presentation/widgets/error_widget.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
/// Wraps a form field with [ValidationErrorWidget] when [error] is present.
|
||||||
|
/// Pass [hasError] into [CustomTextFormField] from the parent.
|
||||||
|
class PassportFieldWithError extends StatelessWidget {
|
||||||
|
const PassportFieldWithError({required this.child, this.error, super.key});
|
||||||
|
|
||||||
|
final Widget child;
|
||||||
|
final String? error;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final message = error?.trim();
|
||||||
|
final showError = message != null && message.isNotEmpty;
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
child,
|
||||||
|
if (showError) ...[
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
ValidationErrorWidget(error: message),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import 'package:eatvio_app/core/theme/custom_text_style.dart';
|
||||||
|
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
class PassportSectionTitle extends StatelessWidget {
|
||||||
|
const PassportSectionTitle({required this.title, super.key});
|
||||||
|
|
||||||
|
final String title;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 8, bottom: 12),
|
||||||
|
child: Text(
|
||||||
|
title,
|
||||||
|
style: TextStyles.textLSemibold.copyWith(color: appTheme.black),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class PassportScreenTitle extends StatelessWidget {
|
||||||
|
const PassportScreenTitle({required this.title, super.key});
|
||||||
|
|
||||||
|
final String title;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Text(
|
||||||
|
title,
|
||||||
|
style: TextStyles.textFS24FW700.copyWith(color: appTheme.black),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
import 'package:eatvio_app/core/theme/custom_text_style.dart';
|
||||||
|
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
||||||
|
import 'package:eatvio_app/features/consultant/become_consultant/enums/alfa_scoring_status.dart';
|
||||||
|
import 'package:eatvio_app/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_cubit.dart';
|
||||||
|
import 'package:eatvio_app/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_state.dart';
|
||||||
|
import 'package:eatvio_app/features/create_diet/presentation/widgets/error_widget.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
|
||||||
|
class ScoringStatusWidget extends StatelessWidget {
|
||||||
|
const ScoringStatusWidget({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return BlocBuilder<RegisterConsultantCubit, RegisterConsultantState>(
|
||||||
|
buildWhen: (previous, current) =>
|
||||||
|
previous.alfaScoringStatus != current.alfaScoringStatus ||
|
||||||
|
previous.isScoringPolling != current.isScoringPolling ||
|
||||||
|
previous.scoringRetryInProgress != current.scoringRetryInProgress ||
|
||||||
|
previous.awaitingScoringOutcome != current.awaitingScoringOutcome,
|
||||||
|
builder: (context, state) {
|
||||||
|
final isWaiting =
|
||||||
|
state.awaitingScoringOutcome || state.isScoringPolling;
|
||||||
|
final status = state.alfaScoringStatus;
|
||||||
|
final showProcessing = status.isProcessing ||
|
||||||
|
(isWaiting && !status.isTerminal);
|
||||||
|
|
||||||
|
if (status == AlfaScoringStatus.none && !isWaiting) {
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
}
|
||||||
|
|
||||||
|
final displayStatus = showProcessing && !status.isProcessing
|
||||||
|
? AlfaScoringStatus.beneficiaryCreating
|
||||||
|
: status;
|
||||||
|
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
|
child: DecoratedBox(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
border: Border.all(color: appTheme.greyD9),
|
||||||
|
color: appTheme.whiteA700,
|
||||||
|
),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
ScoringStatusHeader(status: displayStatus),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
if (showProcessing) const ScoringProcessingBody(),
|
||||||
|
if (status.isComplete) const ScoringCompleteBody(),
|
||||||
|
if (status.isFailed)
|
||||||
|
ScoringFailedBody(
|
||||||
|
retryInProgress: state.scoringRetryInProgress,
|
||||||
|
onRetry: () => context
|
||||||
|
.read<RegisterConsultantCubit>()
|
||||||
|
.retryScoring(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ScoringStatusHeader extends StatelessWidget {
|
||||||
|
const ScoringStatusHeader({required this.status, super.key});
|
||||||
|
|
||||||
|
final AlfaScoringStatus status;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
'Проверка данных',
|
||||||
|
style: TextStyles.textMSemibold.copyWith(color: appTheme.black),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
ScoringStatusLabels.hintFor(status),
|
||||||
|
style: TextStyles.textSMedium.copyWith(
|
||||||
|
color: ScoringStatusLabels.colorFor(status),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ScoringProcessingBody extends StatelessWidget {
|
||||||
|
const ScoringProcessingBody({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Row(
|
||||||
|
children: [
|
||||||
|
SizedBox(
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2,
|
||||||
|
color: appTheme.blueA400,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
'Идёт проверка…',
|
||||||
|
style: TextStyles.textMMediumAuto.copyWith(color: appTheme.black),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ScoringCompleteBody extends StatelessWidget {
|
||||||
|
const ScoringCompleteBody({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.check_circle, color: appTheme.green, size: 22),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
'Проверка успешно завершена',
|
||||||
|
style: TextStyles.textMMediumAuto.copyWith(color: appTheme.black),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ScoringFailedBody extends StatelessWidget {
|
||||||
|
const ScoringFailedBody({
|
||||||
|
required this.retryInProgress,
|
||||||
|
required this.onRetry,
|
||||||
|
super.key,
|
||||||
|
});
|
||||||
|
|
||||||
|
final bool retryInProgress;
|
||||||
|
final VoidCallback onRetry;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const ValidationErrorWidget(error: 'Не удалось пройти проверку'),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
height: 44,
|
||||||
|
child: ElevatedButton(
|
||||||
|
onPressed: retryInProgress ? null : onRetry,
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: appTheme.blueA400,
|
||||||
|
disabledBackgroundColor: appTheme.greyD9,
|
||||||
|
foregroundColor: appTheme.whiteA700,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
),
|
||||||
|
elevation: 0,
|
||||||
|
),
|
||||||
|
child: retryInProgress
|
||||||
|
? SizedBox(
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2,
|
||||||
|
color: appTheme.whiteA700,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: Text(
|
||||||
|
'Повторить',
|
||||||
|
style: TextStyles.textMSemibold.copyWith(
|
||||||
|
color: appTheme.whiteA700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
abstract final class ScoringStatusLabels {
|
||||||
|
static String hintFor(AlfaScoringStatus status) {
|
||||||
|
return switch (status) {
|
||||||
|
AlfaScoringStatus.beneficiaryCreating => 'Создание профиля',
|
||||||
|
AlfaScoringStatus.scoringPending => 'Ожидание результата',
|
||||||
|
AlfaScoringStatus.scoringComplete => 'Успешно',
|
||||||
|
AlfaScoringStatus.beneficiaryFailed => 'Ошибка',
|
||||||
|
AlfaScoringStatus.scoringFailed => 'Ошибка',
|
||||||
|
AlfaScoringStatus.none => '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
static Color colorFor(AlfaScoringStatus status) {
|
||||||
|
return switch (status) {
|
||||||
|
AlfaScoringStatus.scoringComplete => appTheme.green,
|
||||||
|
AlfaScoringStatus.beneficiaryFailed ||
|
||||||
|
AlfaScoringStatus.scoringFailed => appTheme.red,
|
||||||
|
AlfaScoringStatus.beneficiaryCreating ||
|
||||||
|
AlfaScoringStatus.scoringPending => appTheme.blueA400,
|
||||||
|
AlfaScoringStatus.none => appTheme.grey7B,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import 'package:dio/dio.dart';
|
|||||||
import 'package:eatvio_app/features/consultant/become_consultant/models/consultant_cert_photo_model.dart';
|
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/models/consultant_profile_model.dart';
|
||||||
import 'package:eatvio_app/features/consultant/become_consultant/models/consultation_request_model.dart';
|
import 'package:eatvio_app/features/consultant/become_consultant/models/consultation_request_model.dart';
|
||||||
|
import 'package:eatvio_app/features/consultant/become_consultant/models/sbp_bank_model.dart';
|
||||||
import 'package:eatvio_app/features/consultant/become_consultant/services/consultant_service.dart';
|
import 'package:eatvio_app/features/consultant/become_consultant/services/consultant_service.dart';
|
||||||
import 'package:http_parser/http_parser.dart';
|
import 'package:http_parser/http_parser.dart';
|
||||||
|
|
||||||
@@ -11,36 +12,53 @@ class ConsultantRepository {
|
|||||||
|
|
||||||
const ConsultantRepository({required this.consultantService});
|
const ConsultantRepository({required this.consultantService});
|
||||||
|
|
||||||
Future<void> registerConsultant({required ConsultantProfileModel consultant, bool update = false}) async {
|
Future<void> registerConsultant({
|
||||||
|
required ConsultantProfileModel consultant,
|
||||||
|
bool update = false,
|
||||||
|
}) async {
|
||||||
if (update) {
|
if (update) {
|
||||||
return await consultantService.consultantUpdate(consultantModel: consultant);
|
return await consultantService.consultantUpdate(
|
||||||
|
consultantModel: consultant,
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
return await consultantService.consultantRegistration(consultantModel: consultant);
|
return await consultantService.consultantRegistration(
|
||||||
|
consultantModel: consultant,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<ConsultationRequestModel> getConsultantRequestStatus(String slug) async {
|
/// Профиль консультанта (свой или чужой) — публичные данные без паспорта.
|
||||||
|
Future<ConsultationRequestModel> getConsultantRequestStatus(
|
||||||
|
String slug,
|
||||||
|
) async {
|
||||||
return await consultantService.getConsultantRequestStatus(slug: slug);
|
return await consultantService.getConsultantRequestStatus(slug: slug);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Своя заявка — education + legal/passport для регистрации и update.
|
||||||
Future<ConsultationRequestModel> getMyRequestStatus(String slug) async {
|
Future<ConsultationRequestModel> getMyRequestStatus(String slug) async {
|
||||||
return await consultantService.getMyRequestStatus(slug: slug);
|
return await consultantService.getMyRequestStatus(slug: slug);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<SbpBankModel>> getSbpBanks() async {
|
||||||
|
return await consultantService.getSbpBanks();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<List<ConsultantCertPhotoModel>> getConsultantCertPhotos() async {
|
Future<List<ConsultantCertPhotoModel>> getConsultantCertPhotos() async {
|
||||||
return await consultantService.getConsultantCertPhotos();
|
return await consultantService.getConsultantCertPhotos();
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<List<ConsultantCertPhotoModel>> storeConsultantCertPhotos({required List<File> files}) async {
|
Future<List<ConsultantCertPhotoModel>> storeConsultantCertPhotos({
|
||||||
|
required List<File> files,
|
||||||
|
}) async {
|
||||||
final multipartFiles = <MultipartFile>[];
|
final multipartFiles = <MultipartFile>[];
|
||||||
|
|
||||||
for (final file in files) {
|
for (final file in files) {
|
||||||
final fileBytes = await file.readAsBytes();
|
final fileBytes = await file.readAsBytes();
|
||||||
final multipartFile = MultipartFile.fromBytes(fileBytes, filename: file.path.split('/').last, contentType: MediaType('application', 'octet-stream'));
|
final multipartFile = MultipartFile.fromBytes(
|
||||||
|
fileBytes,
|
||||||
|
filename: file.path.split('/').last,
|
||||||
|
contentType: MediaType('application', 'octet-stream'),
|
||||||
|
);
|
||||||
multipartFiles.add(multipartFile);
|
multipartFiles.add(multipartFile);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,22 +66,24 @@ class ConsultantRepository {
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
return await consultantService.storeConsultantCertPhotos(photos: multipartFiles);
|
return await consultantService.storeConsultantCertPhotos(
|
||||||
|
photos: multipartFiles,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> hideConsultantCertPhoto(int id) async {
|
Future<void> hideConsultantCertPhoto(int id) async {
|
||||||
await consultantService.hideConsultantCertPhoto(payload: {'cert_photo_id': id});
|
await consultantService.hideConsultantCertPhoto(
|
||||||
|
payload: {'cert_photo_id': id},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> unHideConsultantCertPhoto(int id) async {
|
Future<void> unHideConsultantCertPhoto(int id) async {
|
||||||
await consultantService.unHideConsultantCertPhoto(payload: {'cert_photo_id': id});
|
await consultantService.unHideConsultantCertPhoto(
|
||||||
|
payload: {'cert_photo_id': id},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> deletePhoto(int id) async {
|
Future<void> deletePhoto(int id) async {
|
||||||
await consultantService.deletePhoto(id: id);
|
await consultantService.deletePhoto(id: id);
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import 'package:dio/dio.dart';
|
|||||||
import 'package:eatvio_app/features/consultant/become_consultant/models/consultant_cert_photo_model.dart';
|
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/models/consultant_profile_model.dart';
|
||||||
import 'package:eatvio_app/features/consultant/become_consultant/models/consultation_request_model.dart';
|
import 'package:eatvio_app/features/consultant/become_consultant/models/consultation_request_model.dart';
|
||||||
|
import 'package:eatvio_app/features/consultant/become_consultant/models/sbp_bank_model.dart';
|
||||||
import 'package:retrofit/retrofit.dart';
|
import 'package:retrofit/retrofit.dart';
|
||||||
|
|
||||||
part 'consultant_service.g.dart';
|
part 'consultant_service.g.dart';
|
||||||
@@ -10,21 +11,39 @@ part 'consultant_service.g.dart';
|
|||||||
abstract class ConsultantService {
|
abstract class ConsultantService {
|
||||||
factory ConsultantService(Dio dio, {String baseUrl}) = _ConsultantService;
|
factory ConsultantService(Dio dio, {String baseUrl}) = _ConsultantService;
|
||||||
|
|
||||||
@POST('/api/consultant_request/registration')
|
@POST('/api/v2/consultant_request/registration')
|
||||||
Future<void> consultantRegistration({@Body() required ConsultantProfileModel consultantModel});
|
Future<void> consultantRegistration({
|
||||||
|
@Body() required ConsultantProfileModel consultantModel,
|
||||||
|
});
|
||||||
|
|
||||||
@POST('/api/consultant_request/photos/upload')
|
@POST('/api/consultant_request/photos/upload')
|
||||||
@MultiPart()
|
@MultiPart()
|
||||||
Future<List<String>> uploadPhotos({@Part(name: 'photos[]', contentType: 'multipart/form-data') List<MultipartFile>? photos});
|
Future<List<String>> uploadPhotos({
|
||||||
|
@Part(name: 'photos[]', contentType: 'multipart/form-data')
|
||||||
|
List<MultipartFile>? photos,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Публичный статус консультанта для просмотра профиля.
|
||||||
|
/// Без паспортных данных: `/api/profile/consultant/{slug}/status`.
|
||||||
@GET('/api/profile/consultant/{slug}/status')
|
@GET('/api/profile/consultant/{slug}/status')
|
||||||
Future<ConsultationRequestModel> getConsultantRequestStatus({@Path() required String slug});
|
Future<ConsultationRequestModel> getConsultantRequestStatus({
|
||||||
|
@Path() required String slug,
|
||||||
|
});
|
||||||
|
|
||||||
@GET('/api/consultant_request/{slug}/status')
|
/// Свой статус заявки для редактирования/модерации.
|
||||||
Future<ConsultationRequestModel> getMyRequestStatus({@Path() required String slug});
|
/// С паспортными полями в `legal_entity_resource`: `/api/v2/consultant_request/{slug}/status`.
|
||||||
|
@GET('/api/v2/consultant_request/{slug}/status')
|
||||||
|
Future<ConsultationRequestModel> getMyRequestStatus({
|
||||||
|
@Path() required String slug,
|
||||||
|
});
|
||||||
|
|
||||||
@PUT('/api/consultant_request/update')
|
@PUT('/api/v2/consultant_request/update')
|
||||||
Future<void> consultantUpdate({@Body() required ConsultantProfileModel consultantModel});
|
Future<void> consultantUpdate({
|
||||||
|
@Body() required ConsultantProfileModel consultantModel,
|
||||||
|
});
|
||||||
|
|
||||||
|
@GET('/api/finances/sbp_banks')
|
||||||
|
Future<List<SbpBankModel>> getSbpBanks();
|
||||||
|
|
||||||
@GET('/api/v2/consultant_request/cert_photos')
|
@GET('/api/v2/consultant_request/cert_photos')
|
||||||
Future<List<ConsultantCertPhotoModel>> getConsultantCertPhotos();
|
Future<List<ConsultantCertPhotoModel>> getConsultantCertPhotos();
|
||||||
@@ -32,14 +51,19 @@ abstract class ConsultantService {
|
|||||||
@POST('/api/v2/consultant_request/cert_photos/store')
|
@POST('/api/v2/consultant_request/cert_photos/store')
|
||||||
@MultiPart()
|
@MultiPart()
|
||||||
Future<List<ConsultantCertPhotoModel>> storeConsultantCertPhotos({
|
Future<List<ConsultantCertPhotoModel>> storeConsultantCertPhotos({
|
||||||
@Part(name: 'photos[]', contentType: 'multipart/form-data') required List<MultipartFile> photos,
|
@Part(name: 'photos[]', contentType: 'multipart/form-data')
|
||||||
|
required List<MultipartFile> photos,
|
||||||
});
|
});
|
||||||
|
|
||||||
@POST('/api/v2/consultant_request/cert_photos/hide')
|
@POST('/api/v2/consultant_request/cert_photos/hide')
|
||||||
Future<void> hideConsultantCertPhoto({@Body() required Map<String, dynamic> payload});
|
Future<void> hideConsultantCertPhoto({
|
||||||
|
@Body() required Map<String, dynamic> payload,
|
||||||
|
});
|
||||||
|
|
||||||
@POST('/api/v2/consultant_request/cert_photos/un_hide')
|
@POST('/api/v2/consultant_request/cert_photos/un_hide')
|
||||||
Future<void> unHideConsultantCertPhoto({@Body() required Map<String, dynamic> payload});
|
Future<void> unHideConsultantCertPhoto({
|
||||||
|
@Body() required Map<String, dynamic> payload,
|
||||||
|
});
|
||||||
|
|
||||||
@POST('/api/v2/consultant_request/cert_photos/{id}/queue-for-deletion')
|
@POST('/api/v2/consultant_request/cert_photos/{id}/queue-for-deletion')
|
||||||
Future<void> deletePhoto({@Path() required int id});
|
Future<void> deletePhoto({@Path() required int id});
|
||||||
|
|||||||
@@ -0,0 +1,170 @@
|
|||||||
|
import 'package:dio/dio.dart';
|
||||||
|
|
||||||
|
/// Parsed Laravel 422 validation payload for consultant registration UI.
|
||||||
|
class LaravelValidationErrors {
|
||||||
|
const LaravelValidationErrors({
|
||||||
|
required this.summary,
|
||||||
|
this.fieldErrors = const <String, String>{},
|
||||||
|
});
|
||||||
|
|
||||||
|
final String summary;
|
||||||
|
final Map<String, String> fieldErrors;
|
||||||
|
|
||||||
|
static const LaravelValidationErrors empty = LaravelValidationErrors(
|
||||||
|
summary: '',
|
||||||
|
);
|
||||||
|
|
||||||
|
bool get hasFieldErrors => fieldErrors.isNotEmpty;
|
||||||
|
}
|
||||||
|
|
||||||
|
const _genericValidationSummary = 'Проверьте введённые данные';
|
||||||
|
const _genericNetworkSummary =
|
||||||
|
'Не удалось отправить данные. Попробуйте ещё раз';
|
||||||
|
|
||||||
|
/// Parses Laravel `errors` map and maps them to user-facing Russian texts.
|
||||||
|
/// Raw backend messages are never shown in the UI.
|
||||||
|
LaravelValidationErrors parseLaravelValidationErrors(Object error) {
|
||||||
|
if (error is! DioException) {
|
||||||
|
return const LaravelValidationErrors(summary: _genericNetworkSummary);
|
||||||
|
}
|
||||||
|
|
||||||
|
final data = error.response?.data;
|
||||||
|
if (error.response?.statusCode == 422 && data is Map) {
|
||||||
|
final fieldErrors = <String, String>{};
|
||||||
|
final errors = data['errors'];
|
||||||
|
|
||||||
|
if (errors is Map) {
|
||||||
|
errors.forEach((key, value) {
|
||||||
|
final fieldKey = key.toString();
|
||||||
|
final raw = _rawErrorText(value);
|
||||||
|
fieldErrors[fieldKey] = mapConsultantValidationMessage(
|
||||||
|
fieldKey: fieldKey,
|
||||||
|
rawMessage: raw,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fieldErrors.isNotEmpty) {
|
||||||
|
final uniqueMessages = fieldErrors.values.toSet().toList();
|
||||||
|
return LaravelValidationErrors(
|
||||||
|
summary: uniqueMessages.join('\n'),
|
||||||
|
fieldErrors: fieldErrors,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return const LaravelValidationErrors(summary: _genericValidationSummary);
|
||||||
|
}
|
||||||
|
|
||||||
|
return const LaravelValidationErrors(summary: _genericNetworkSummary);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _rawErrorText(Object? value) {
|
||||||
|
if (value is List && value.isNotEmpty) {
|
||||||
|
return value.map((e) => e.toString()).join(' ');
|
||||||
|
}
|
||||||
|
if (value is String) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Maps a backend validation message for [fieldKey] to a short Russian UI string.
|
||||||
|
String mapConsultantValidationMessage({
|
||||||
|
required String fieldKey,
|
||||||
|
required String rawMessage,
|
||||||
|
}) {
|
||||||
|
final field = fieldKey.split('.').last.toLowerCase();
|
||||||
|
final raw = rawMessage.toLowerCase();
|
||||||
|
|
||||||
|
final isUnique =
|
||||||
|
raw.contains('taken') ||
|
||||||
|
raw.contains('unique') ||
|
||||||
|
raw.contains('already') ||
|
||||||
|
raw.contains('уже существ') ||
|
||||||
|
raw.contains('уже занят');
|
||||||
|
final isRequired =
|
||||||
|
raw.contains('required') ||
|
||||||
|
raw.contains('обязательн') ||
|
||||||
|
raw.contains('required_if');
|
||||||
|
final isAccepted = raw.contains('accepted') || field.contains('agreement');
|
||||||
|
final isUrl = raw.contains('url') || field.contains('link');
|
||||||
|
final isFormat =
|
||||||
|
raw.contains('format') ||
|
||||||
|
raw.contains('invalid') ||
|
||||||
|
raw.contains('regex') ||
|
||||||
|
raw.contains('digits') ||
|
||||||
|
raw.contains('size') ||
|
||||||
|
raw.contains('date') ||
|
||||||
|
raw.contains('before');
|
||||||
|
|
||||||
|
if (isAccepted) {
|
||||||
|
if (field.contains('frdo')) {
|
||||||
|
return 'Подтвердите согласие ФРДО';
|
||||||
|
}
|
||||||
|
return 'Примите условия оферты';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isUnique) {
|
||||||
|
return switch (field) {
|
||||||
|
'inn' => 'Такой ИНН существует',
|
||||||
|
'snils' => 'Такой СНИЛС существует',
|
||||||
|
'passport_number' || 'passport_series' => 'Такой паспорт существует',
|
||||||
|
'sbp_phone' => 'Такой телефон СБП существует',
|
||||||
|
_ => 'Такие данные уже существуют',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isRequired) {
|
||||||
|
return switch (field) {
|
||||||
|
'inn' => 'Укажите ИНН',
|
||||||
|
'snils' => 'Укажите СНИЛС',
|
||||||
|
'last_name' => 'Укажите фамилию',
|
||||||
|
'first_name' => 'Укажите имя',
|
||||||
|
'middle_name' => 'Укажите отчество',
|
||||||
|
'passport_series' => 'Укажите серию паспорта',
|
||||||
|
'passport_number' => 'Укажите номер паспорта',
|
||||||
|
'passport_issue_date' => 'Укажите дату выдачи паспорта',
|
||||||
|
'passport_issuer' => 'Укажите, кем выдан паспорт',
|
||||||
|
'passport_issuer_code' => 'Укажите код подразделения',
|
||||||
|
'birth_date' => 'Укажите дату рождения',
|
||||||
|
'birth_place' => 'Укажите место рождения',
|
||||||
|
'registration_address' => 'Укажите адрес регистрации',
|
||||||
|
'residence_address' => 'Укажите адрес проживания',
|
||||||
|
'citizenship' => 'Укажите гражданство',
|
||||||
|
'sbp_phone' => 'Укажите телефон СБП',
|
||||||
|
'sbp_bank_member_id' => 'Выберите банк СБП',
|
||||||
|
'completed_courses' => 'Добавьте хотя бы один курс',
|
||||||
|
'title' => 'Укажите название курса',
|
||||||
|
'date' => 'Укажите дату курса',
|
||||||
|
'agreement_accepted' => 'Примите условия оферты',
|
||||||
|
'frdo_agreement_accepted' => 'Подтвердите согласие ФРДО',
|
||||||
|
_ => 'Заполните поле',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isUrl) {
|
||||||
|
return 'Некорректная ссылка';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isFormat || raw.isNotEmpty) {
|
||||||
|
return switch (field) {
|
||||||
|
'inn' => 'Некорректный ИНН',
|
||||||
|
'snils' => 'Некорректный СНИЛС',
|
||||||
|
'passport_series' => 'Некорректная серия паспорта',
|
||||||
|
'passport_number' => 'Некорректный номер паспорта',
|
||||||
|
'passport_issue_date' => 'Некорректная дата выдачи',
|
||||||
|
'passport_issuer_code' => 'Некорректный код подразделения',
|
||||||
|
'last_name' => 'Некорректная фамилия',
|
||||||
|
'first_name' => 'Некорректное имя',
|
||||||
|
'middle_name' => 'Некорректное отчество',
|
||||||
|
'birth_date' => 'Некорректная дата рождения',
|
||||||
|
'citizenship' => 'Некорректный код гражданства',
|
||||||
|
'sbp_phone' => 'Некорректный телефон СБП',
|
||||||
|
'title' => 'Слишком короткое название курса',
|
||||||
|
'date' => 'Некорректная дата курса',
|
||||||
|
_ => 'Некорректное значение',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'Проверьте поле';
|
||||||
|
}
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
import 'package:eatvio_app/features/consultant/become_consultant/models/consultant_profile_model.dart';
|
||||||
|
|
||||||
|
/// Client-side validation for individual-person passport fields.
|
||||||
|
///
|
||||||
|
/// Messages match [mapConsultantValidationMessage] so 422 and local errors
|
||||||
|
/// look the same in the UI. Dates also reject non-calendar values and
|
||||||
|
/// dates after today.
|
||||||
|
abstract final class PassportDataValidator {
|
||||||
|
static const futureDateMessage = 'Дата не может быть в будущем';
|
||||||
|
|
||||||
|
/// Alfa Bank `personalInformation.{last,first,middle}Name` pattern.
|
||||||
|
static final alfaPersonNamePattern = RegExp(r'^[a-zA-Zа-яА-ЯёЁ ,.-]{1,50}$');
|
||||||
|
|
||||||
|
static Map<String, String> validate(
|
||||||
|
ConsultantProfileModel consultant, {
|
||||||
|
DateTime? now,
|
||||||
|
}) {
|
||||||
|
final today = now ?? DateTime.now();
|
||||||
|
final errors = <String, String>{};
|
||||||
|
|
||||||
|
_requirePattern(
|
||||||
|
errors,
|
||||||
|
key: 'last_name',
|
||||||
|
value: consultant.lastName,
|
||||||
|
pattern: alfaPersonNamePattern,
|
||||||
|
emptyMessage: 'Укажите фамилию',
|
||||||
|
invalidMessage: 'Некорректная фамилия',
|
||||||
|
);
|
||||||
|
_requirePattern(
|
||||||
|
errors,
|
||||||
|
key: 'first_name',
|
||||||
|
value: consultant.firstName,
|
||||||
|
pattern: alfaPersonNamePattern,
|
||||||
|
emptyMessage: 'Укажите имя',
|
||||||
|
invalidMessage: 'Некорректное имя',
|
||||||
|
);
|
||||||
|
_optionalPattern(
|
||||||
|
errors,
|
||||||
|
key: 'middle_name',
|
||||||
|
value: consultant.middleName,
|
||||||
|
pattern: alfaPersonNamePattern,
|
||||||
|
invalidMessage: 'Некорректное отчество',
|
||||||
|
);
|
||||||
|
_requirePattern(
|
||||||
|
errors,
|
||||||
|
key: 'inn',
|
||||||
|
value: consultant.inn,
|
||||||
|
pattern: RegExp(r'^\d{12}$'),
|
||||||
|
emptyMessage: 'Укажите ИНН',
|
||||||
|
invalidMessage: 'Некорректный ИНН',
|
||||||
|
);
|
||||||
|
_requirePattern(
|
||||||
|
errors,
|
||||||
|
key: 'snils',
|
||||||
|
value: consultant.snils,
|
||||||
|
pattern: RegExp(r'^\d{3}-\d{3}-\d{3} \d{2}$'),
|
||||||
|
emptyMessage: 'Укажите СНИЛС',
|
||||||
|
invalidMessage: 'Некорректный СНИЛС',
|
||||||
|
);
|
||||||
|
_requirePattern(
|
||||||
|
errors,
|
||||||
|
key: 'passport_series',
|
||||||
|
value: consultant.passportSeries,
|
||||||
|
pattern: RegExp(r'^\d{4}$'),
|
||||||
|
emptyMessage: 'Укажите серию паспорта',
|
||||||
|
invalidMessage: 'Некорректная серия паспорта',
|
||||||
|
);
|
||||||
|
_requirePattern(
|
||||||
|
errors,
|
||||||
|
key: 'passport_number',
|
||||||
|
value: consultant.passportNumber,
|
||||||
|
pattern: RegExp(r'^\d{6}$'),
|
||||||
|
emptyMessage: 'Укажите номер паспорта',
|
||||||
|
invalidMessage: 'Некорректный номер паспорта',
|
||||||
|
);
|
||||||
|
_requireText(
|
||||||
|
errors,
|
||||||
|
key: 'passport_issuer',
|
||||||
|
value: consultant.passportIssuer,
|
||||||
|
emptyMessage: 'Укажите, кем выдан паспорт',
|
||||||
|
);
|
||||||
|
_requirePattern(
|
||||||
|
errors,
|
||||||
|
key: 'passport_issuer_code',
|
||||||
|
value: consultant.passportIssuerCode,
|
||||||
|
pattern: RegExp(r'^\d{3}-\d{3}$'),
|
||||||
|
emptyMessage: 'Укажите код подразделения',
|
||||||
|
invalidMessage: 'Некорректный код подразделения',
|
||||||
|
);
|
||||||
|
_requireDate(
|
||||||
|
errors,
|
||||||
|
key: 'passport_issue_date',
|
||||||
|
value: consultant.passportIssueDate,
|
||||||
|
emptyMessage: 'Укажите дату выдачи паспорта',
|
||||||
|
invalidMessage: 'Некорректная дата выдачи',
|
||||||
|
today: today,
|
||||||
|
);
|
||||||
|
_requireDate(
|
||||||
|
errors,
|
||||||
|
key: 'birth_date',
|
||||||
|
value: consultant.birthDate,
|
||||||
|
emptyMessage: 'Укажите дату рождения',
|
||||||
|
invalidMessage: 'Некорректная дата рождения',
|
||||||
|
today: today,
|
||||||
|
);
|
||||||
|
_requireText(
|
||||||
|
errors,
|
||||||
|
key: 'birth_place',
|
||||||
|
value: consultant.birthPlace,
|
||||||
|
emptyMessage: 'Укажите место рождения',
|
||||||
|
);
|
||||||
|
_requireText(
|
||||||
|
errors,
|
||||||
|
key: 'registration_address',
|
||||||
|
value: consultant.registrationAddress,
|
||||||
|
emptyMessage: 'Укажите адрес регистрации',
|
||||||
|
);
|
||||||
|
_requireText(
|
||||||
|
errors,
|
||||||
|
key: 'residence_address',
|
||||||
|
value: consultant.residenceAddress,
|
||||||
|
emptyMessage: 'Укажите адрес проживания',
|
||||||
|
);
|
||||||
|
_requirePattern(
|
||||||
|
errors,
|
||||||
|
key: 'citizenship',
|
||||||
|
value: consultant.citizenship,
|
||||||
|
pattern: RegExp(r'^[A-Za-z]{2}$'),
|
||||||
|
emptyMessage: 'Укажите гражданство',
|
||||||
|
invalidMessage: 'Некорректный код гражданства',
|
||||||
|
);
|
||||||
|
|
||||||
|
return errors;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void _requireText(
|
||||||
|
Map<String, String> errors, {
|
||||||
|
required String key,
|
||||||
|
required String? value,
|
||||||
|
required String emptyMessage,
|
||||||
|
}) {
|
||||||
|
if (value == null || value.trim().isEmpty) {
|
||||||
|
errors[key] = emptyMessage;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void _requirePattern(
|
||||||
|
Map<String, String> errors, {
|
||||||
|
required String key,
|
||||||
|
required String? value,
|
||||||
|
required RegExp pattern,
|
||||||
|
required String emptyMessage,
|
||||||
|
required String invalidMessage,
|
||||||
|
}) {
|
||||||
|
final raw = value?.trim() ?? '';
|
||||||
|
if (raw.isEmpty) {
|
||||||
|
errors[key] = emptyMessage;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!pattern.hasMatch(raw)) {
|
||||||
|
errors[key] = invalidMessage;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void _optionalPattern(
|
||||||
|
Map<String, String> errors, {
|
||||||
|
required String key,
|
||||||
|
required String? value,
|
||||||
|
required RegExp pattern,
|
||||||
|
required String invalidMessage,
|
||||||
|
}) {
|
||||||
|
final raw = value?.trim() ?? '';
|
||||||
|
if (raw.isEmpty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!pattern.hasMatch(raw)) {
|
||||||
|
errors[key] = invalidMessage;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void _requireDate(
|
||||||
|
Map<String, String> errors, {
|
||||||
|
required String key,
|
||||||
|
required String? value,
|
||||||
|
required String emptyMessage,
|
||||||
|
required String invalidMessage,
|
||||||
|
required DateTime today,
|
||||||
|
}) {
|
||||||
|
final raw = value?.trim() ?? '';
|
||||||
|
if (raw.isEmpty) {
|
||||||
|
errors[key] = emptyMessage;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final match = RegExp(r'^(\d{4})-(\d{2})-(\d{2})$').firstMatch(raw);
|
||||||
|
if (match == null) {
|
||||||
|
errors[key] = invalidMessage;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final year = int.parse(match.group(1)!);
|
||||||
|
final month = int.parse(match.group(2)!);
|
||||||
|
final day = int.parse(match.group(3)!);
|
||||||
|
final parsed = DateTime(year, month, day);
|
||||||
|
if (parsed.year != year || parsed.month != month || parsed.day != day) {
|
||||||
|
errors[key] = invalidMessage;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final todayDate = DateTime(today.year, today.month, today.day);
|
||||||
|
if (parsed.isAfter(todayDate)) {
|
||||||
|
errors[key] = futureDateMessage;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -59,43 +59,47 @@ class _AddMealWidgetState extends State<AddMealWidget> {
|
|||||||
Row(
|
Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
SizedBox(
|
Expanded(
|
||||||
width: MediaQuery.of(context).size.width * 0.8 - 32,
|
flex: 2,
|
||||||
child: CustomTextFormField(
|
child: SizedBox(
|
||||||
controller: productNameController,
|
child: CustomTextFormField(
|
||||||
autofocus: true,
|
controller: productNameController,
|
||||||
hintText: 'Введите название продукта/блюда',
|
autofocus: true,
|
||||||
onChanged: (query) {
|
hintText: 'Введите название продукта/блюда',
|
||||||
EasyDebounce.debounce('glossary-search', const Duration(milliseconds: 250), () async {
|
onChanged: (query) {
|
||||||
selectedGlossary = null;
|
EasyDebounce.debounce('glossary-search', const Duration(milliseconds: 250), () async {
|
||||||
if (query.isEmpty) {
|
|
||||||
glossariesList = [];
|
|
||||||
selectedGlossary = null;
|
selectedGlossary = null;
|
||||||
|
if (query.isEmpty) {
|
||||||
|
glossariesList = [];
|
||||||
|
selectedGlossary = null;
|
||||||
|
setState(() {});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
glossariesList = await widget.createDietCubit.getGlossaryListByQuery(query);
|
||||||
setState(() {});
|
setState(() {});
|
||||||
return;
|
});
|
||||||
}
|
},
|
||||||
glossariesList = await widget.createDietCubit.getGlossaryListByQuery(query);
|
),
|
||||||
setState(() {});
|
|
||||||
});
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 4),
|
const SizedBox(width: 4),
|
||||||
SizedBox(
|
Expanded(
|
||||||
width: MediaQuery.of(context).size.width * 0.2 - 4,
|
flex: 1,
|
||||||
child: CustomTextFormField(
|
child: SizedBox(
|
||||||
controller: quantityController,
|
child: CustomTextFormField(
|
||||||
keyboardType: TextInputType.number,
|
controller: quantityController,
|
||||||
hintText: selectedGlossary == null
|
keyboardType: TextInputType.number,
|
||||||
? '100'
|
hintText: selectedGlossary == null
|
||||||
: selectedGlossary!.grInPortion == 0
|
? '100'
|
||||||
? '100'
|
: selectedGlossary!.grInPortion == 0
|
||||||
: '1',
|
? '100'
|
||||||
suffixText: selectedGlossary == null
|
: '1',
|
||||||
? 'гр'
|
suffixText: selectedGlossary == null
|
||||||
: selectedGlossary?.grInPortion == 0
|
? 'гр'
|
||||||
? 'гр'
|
: selectedGlossary?.grInPortion == 0
|
||||||
: 'шт',
|
? 'гр'
|
||||||
|
: 'шт',
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -149,17 +153,19 @@ class _AddMealWidgetState extends State<AddMealWidget> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (widget.portionIndex != null) const SizedBox(width: 8),
|
if (widget.portionIndex != null) const SizedBox(width: 8),
|
||||||
InkWell(
|
Expanded(
|
||||||
onTap: addPortion,
|
child: InkWell(
|
||||||
borderRadius: BorderRadius.circular(20),
|
onTap: addPortion,
|
||||||
child: Ink(
|
borderRadius: BorderRadius.circular(20),
|
||||||
width: MediaQuery.of(context).size.width - (widget.portionIndex == null ? 32 : 92),
|
child: Ink(
|
||||||
height: 52,
|
width: MediaQuery.of(context).size.width - (widget.portionIndex == null ? 32 : 92),
|
||||||
decoration: BoxDecoration(borderRadius: BorderRadius.circular(20), color: appTheme.blueA400),
|
height: 52,
|
||||||
child: Center(
|
decoration: BoxDecoration(borderRadius: BorderRadius.circular(20), color: appTheme.blueA400),
|
||||||
child: Text(
|
child: Center(
|
||||||
widget.portionIndex == null ? 'Добавить' : 'Сохранить',
|
child: Text(
|
||||||
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: Colors.white),
|
widget.portionIndex == null ? 'Добавить' : 'Сохранить',
|
||||||
|
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: Colors.white),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -70,42 +70,46 @@ class _AddProductModalState extends State<AddProductModal> {
|
|||||||
Row(
|
Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
SizedBox(
|
Expanded(
|
||||||
width: MediaQuery.of(context).size.width * 0.76 - 32,
|
flex: 2,
|
||||||
child: CustomTextFormField(
|
child: SizedBox(
|
||||||
controller: productNameController,
|
child: CustomTextFormField(
|
||||||
autofocus: true,
|
controller: productNameController,
|
||||||
hintText: 'Введите название продукта/блюда',
|
autofocus: true,
|
||||||
onChanged: (query) {
|
hintText: 'Введите название продукта/блюда',
|
||||||
EasyDebounce.debounce('glossary-search', const Duration(milliseconds: 250), () async {
|
onChanged: (query) {
|
||||||
selectedGlossary = null;
|
EasyDebounce.debounce('glossary-search', const Duration(milliseconds: 250), () async {
|
||||||
if (query.isEmpty) {
|
selectedGlossary = null;
|
||||||
glossariesList = [];
|
if (query.isEmpty) {
|
||||||
|
glossariesList = [];
|
||||||
|
setState(() {});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
glossariesList = await widget.diaryCubit.getGlossaryListByQuery(query);
|
||||||
setState(() {});
|
setState(() {});
|
||||||
return;
|
});
|
||||||
}
|
},
|
||||||
glossariesList = await widget.diaryCubit.getGlossaryListByQuery(query);
|
),
|
||||||
setState(() {});
|
|
||||||
});
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 4),
|
const SizedBox(width: 4),
|
||||||
SizedBox(
|
Expanded(
|
||||||
width: MediaQuery.of(context).size.width * 0.24 - 4,
|
flex: 1,
|
||||||
child: CustomTextFormField(
|
child: SizedBox(
|
||||||
controller: quantityController,
|
child: CustomTextFormField(
|
||||||
keyboardType: TextInputType.number,
|
controller: quantityController,
|
||||||
hintText: selectedGlossary == null
|
keyboardType: TextInputType.number,
|
||||||
? '100'
|
hintText: selectedGlossary == null
|
||||||
: selectedGlossary!.grInPortion == 0
|
? '100'
|
||||||
? '100'
|
: selectedGlossary!.grInPortion == 0
|
||||||
: '1',
|
? '100'
|
||||||
suffixText: selectedGlossary == null
|
: '1',
|
||||||
? 'гр'
|
suffixText: selectedGlossary == null
|
||||||
: selectedGlossary?.grInPortion == 0
|
? 'гр'
|
||||||
? 'гр'
|
: selectedGlossary?.grInPortion == 0
|
||||||
: 'шт',
|
? 'гр'
|
||||||
|
: 'шт',
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -168,66 +172,68 @@ class _AddProductModalState extends State<AddProductModal> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (widget.portion != null) const SizedBox(width: 8),
|
if (widget.portion != null) const SizedBox(width: 8),
|
||||||
InkWell(
|
Expanded(
|
||||||
onTap: () {
|
child: InkWell(
|
||||||
// Автоматически выбираем первый элемент, если текст точно совпадает
|
onTap: () {
|
||||||
if (selectedGlossary == null &&
|
// Автоматически выбираем первый элемент, если текст точно совпадает
|
||||||
glossariesList.isNotEmpty &&
|
if (selectedGlossary == null &&
|
||||||
productNameController.text.trim().toLowerCase() == glossariesList[0].name.toLowerCase()) {
|
glossariesList.isNotEmpty &&
|
||||||
selectedGlossary = glossariesList[0];
|
productNameController.text.trim().toLowerCase() == glossariesList[0].name.toLowerCase()) {
|
||||||
}
|
selectedGlossary = glossariesList[0];
|
||||||
|
}
|
||||||
|
|
||||||
if (widget.portion == null) {
|
if (widget.portion == null) {
|
||||||
widget.diaryCubit.addProductToMeal(
|
widget.diaryCubit.addProductToMeal(
|
||||||
diaryId: widget.state.diary.id,
|
diaryId: widget.state.diary.id,
|
||||||
mealId: widget.state.selectedMeal,
|
mealId: widget.state.selectedMeal,
|
||||||
productName: productNameController.text,
|
productName: productNameController.text,
|
||||||
portion: selectedGlossary == null
|
portion: selectedGlossary == null
|
||||||
? 'gr'
|
? 'gr'
|
||||||
: selectedGlossary!.grInPortion == 0
|
: selectedGlossary!.grInPortion == 0
|
||||||
? 'gr'
|
? 'gr'
|
||||||
: 'pcs',
|
: 'pcs',
|
||||||
quantity: int.parse(quantityController.text),
|
quantity: int.parse(quantityController.text),
|
||||||
productNutritionalValueId: selectedGlossary?.id,
|
productNutritionalValueId: selectedGlossary?.id,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
widget.diaryCubit.updateProductInMeal(
|
widget.diaryCubit.updateProductInMeal(
|
||||||
diaryId: widget.state.diary.id,
|
diaryId: widget.state.diary.id,
|
||||||
mealId: widget.state.selectedMeal,
|
mealId: widget.state.selectedMeal,
|
||||||
portionId: widget.portion!.id,
|
portionId: widget.portion!.id,
|
||||||
productName: productNameController.text,
|
productName: productNameController.text,
|
||||||
portion: selectedGlossary == null
|
portion: selectedGlossary == null
|
||||||
? 'gr'
|
? 'gr'
|
||||||
: selectedGlossary!.grInPortion == 0
|
: selectedGlossary!.grInPortion == 0
|
||||||
? 'gr'
|
? 'gr'
|
||||||
: 'pcs',
|
: 'pcs',
|
||||||
quantity: int.parse(quantityController.text),
|
quantity: int.parse(quantityController.text),
|
||||||
productNutritionalValueId: selectedGlossary?.id,
|
productNutritionalValueId: selectedGlossary?.id,
|
||||||
portionState: widget.portion!.portionState,
|
portionState: widget.portion!.portionState,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
Navigator.of(context).pop();
|
Navigator.of(context).pop();
|
||||||
if (selectedGlossary == null && _previousProductName != productNameController.text) {
|
if (selectedGlossary == null && _previousProductName != productNameController.text) {
|
||||||
showModalBottomSheet(
|
showModalBottomSheet(
|
||||||
isScrollControlled: true,
|
isScrollControlled: true,
|
||||||
useRootNavigator: true,
|
useRootNavigator: true,
|
||||||
context: context,
|
context: context,
|
||||||
builder: (context) => Padding(
|
builder: (context) => Padding(
|
||||||
padding: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom),
|
padding: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom),
|
||||||
child: GlossaryModerationRequestModalWidget(productName: productNameController.text, diaryCubit: widget.diaryCubit),
|
child: GlossaryModerationRequestModalWidget(productName: productNameController.text, diaryCubit: widget.diaryCubit),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
child: Ink(
|
||||||
|
width: MediaQuery.of(context).size.width - (widget.portion == null ? 32 : 92),
|
||||||
|
height: 52,
|
||||||
|
decoration: BoxDecoration(borderRadius: BorderRadius.circular(20), color: appTheme.blueA400),
|
||||||
|
child: Center(
|
||||||
|
child: Text(
|
||||||
|
widget.portion == null ? 'Добавить' : 'Сохранить',
|
||||||
|
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: Colors.white),
|
||||||
),
|
),
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
borderRadius: BorderRadius.circular(20),
|
|
||||||
child: Ink(
|
|
||||||
width: MediaQuery.of(context).size.width - (widget.portion == null ? 32 : 92),
|
|
||||||
height: 52,
|
|
||||||
decoration: BoxDecoration(borderRadius: BorderRadius.circular(20), color: appTheme.blueA400),
|
|
||||||
child: Center(
|
|
||||||
child: Text(
|
|
||||||
widget.portion == null ? 'Добавить' : 'Сохранить',
|
|
||||||
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: Colors.white),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'package:eatvio_app/features/feed/models/diets_pagination_model.dart';
|
import 'package:eatvio_app/features/feed/models/diets_pagination_model.dart';
|
||||||
|
import 'package:eatvio_app/features/feed/models/post_feed_pagination_model.dart';
|
||||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||||
|
|
||||||
part 'feed_blog_post_model.freezed.dart';
|
part 'feed_blog_post_model.freezed.dart';
|
||||||
@@ -20,6 +21,9 @@ abstract class FeedBlogPostModel with _$FeedBlogPostModel {
|
|||||||
@JsonKey(name: "total_views") required int totalViews,
|
@JsonKey(name: "total_views") required int totalViews,
|
||||||
@JsonKey(name: "total_comments") required int totalComments,
|
@JsonKey(name: "total_comments") required int totalComments,
|
||||||
@JsonKey(name: "was_liked_by_me") required bool wasLikedByMe,
|
@JsonKey(name: "was_liked_by_me") required bool wasLikedByMe,
|
||||||
|
@Default([]) List<FeedGalleryImage> images,
|
||||||
|
@JsonKey(name: "video_url_embed") String? videoEmbedUrl,
|
||||||
|
@JsonKey(name: "video_url_thumb") String? videoUrlThumb,
|
||||||
}) = _FeedBlogPostModel;
|
}) = _FeedBlogPostModel;
|
||||||
|
|
||||||
factory FeedBlogPostModel.fromJson(Map<String, Object?> json) => _$FeedBlogPostModelFromJson(json);
|
factory FeedBlogPostModel.fromJson(Map<String, Object?> json) => _$FeedBlogPostModelFromJson(json);
|
||||||
|
|||||||
41
lib/features/feed/models/post_feed_mapper.dart
Normal file
41
lib/features/feed/models/post_feed_mapper.dart
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import 'package:eatvio_app/core/app_config.dart';
|
||||||
|
import 'package:eatvio_app/core/locator_service.dart';
|
||||||
|
import 'package:eatvio_app/features/feed/models/diets_pagination_model.dart';
|
||||||
|
import 'package:eatvio_app/features/feed/models/feed_blog_post_model.dart';
|
||||||
|
import 'package:eatvio_app/features/feed/models/post_feed_pagination_model.dart';
|
||||||
|
|
||||||
|
class PostFeedMapper {
|
||||||
|
static FeedBlogPostModel fromPostFeedItem(PostFeedItemModel item, {int? currentUserId}) {
|
||||||
|
return FeedBlogPostModel(
|
||||||
|
id: item.postId,
|
||||||
|
state: item.postState,
|
||||||
|
image: resolveMediaUrl(item.postImageUrl),
|
||||||
|
description: item.postTextMd ?? item.postShortDescription ?? '',
|
||||||
|
author: Author(
|
||||||
|
authorSlug: item.postAuthorDetails?.slug ?? '',
|
||||||
|
authorName: item.postAuthorName,
|
||||||
|
authorProfileIcon: resolveMediaUrl(item.postAuthorImageUrl),
|
||||||
|
authorIsMe: currentUserId != null && item.postAuthorId == currentUserId,
|
||||||
|
isEntitySubscribedByMe: item.isEntitySubscribedByMe,
|
||||||
|
),
|
||||||
|
videoUrl: '',
|
||||||
|
createdAt: item.postCreatedAt,
|
||||||
|
updatedAt: item.updatedAt,
|
||||||
|
title: item.postTitle,
|
||||||
|
totalLikes: item.postLikesCount,
|
||||||
|
totalViews: item.postViewsCount,
|
||||||
|
totalComments: item.postCommentsCount,
|
||||||
|
wasLikedByMe: item.postIsLikedByUser,
|
||||||
|
images: item.images,
|
||||||
|
videoEmbedUrl: item.videoEmbedUrl,
|
||||||
|
videoUrlThumb: item.videoUrlThumb,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static String resolveMediaUrl(String? url) {
|
||||||
|
if (url == null || url.isEmpty) return '';
|
||||||
|
if (url.startsWith('http://') || url.startsWith('https://')) return url;
|
||||||
|
if (url.startsWith('/')) return '${sl<AppConfig>().baseUrl}$url';
|
||||||
|
return '${sl<AppConfig>().baseUrl}/storage/$url';
|
||||||
|
}
|
||||||
|
}
|
||||||
95
lib/features/feed/models/post_feed_pagination_model.dart
Normal file
95
lib/features/feed/models/post_feed_pagination_model.dart
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||||
|
|
||||||
|
part 'post_feed_pagination_model.freezed.dart';
|
||||||
|
part 'post_feed_pagination_model.g.dart';
|
||||||
|
|
||||||
|
@freezed
|
||||||
|
abstract class PostFeedPaginationModel with _$PostFeedPaginationModel {
|
||||||
|
const factory PostFeedPaginationModel({
|
||||||
|
@JsonKey(name: 'data') required List<PostFeedItemModel> data,
|
||||||
|
required CursorLinks links,
|
||||||
|
required CursorMeta meta,
|
||||||
|
}) = _PostFeedPaginationModel;
|
||||||
|
|
||||||
|
factory PostFeedPaginationModel.fromJson(Map<String, Object?> json) => _$PostFeedPaginationModelFromJson(json);
|
||||||
|
}
|
||||||
|
|
||||||
|
@freezed
|
||||||
|
abstract class PostFeedItemModel with _$PostFeedItemModel {
|
||||||
|
const factory PostFeedItemModel({
|
||||||
|
required int id,
|
||||||
|
@JsonKey(name: 'post_id') required int postId,
|
||||||
|
@JsonKey(name: 'post_author_id') required int postAuthorId,
|
||||||
|
@JsonKey(name: 'post_author_name') required String postAuthorName,
|
||||||
|
@JsonKey(name: 'post_author_image_url') String? postAuthorImageUrl,
|
||||||
|
@JsonKey(name: 'post_author_profile_url') String? postAuthorProfileUrl,
|
||||||
|
@JsonKey(name: 'post_created_at', fromJson: parseDateTime) required DateTime postCreatedAt,
|
||||||
|
@JsonKey(name: 'post_title') required String postTitle,
|
||||||
|
@JsonKey(name: 'post_short_description') String? postShortDescription,
|
||||||
|
@JsonKey(name: 'post_text_md') String? postTextMd,
|
||||||
|
@JsonKey(name: 'post_image_url') String? postImageUrl,
|
||||||
|
@Default([]) List<FeedGalleryImage> images,
|
||||||
|
@JsonKey(name: 'video_embed_url') String? videoEmbedUrl,
|
||||||
|
@JsonKey(name: 'post_views_count') required int postViewsCount,
|
||||||
|
@JsonKey(name: 'post_likes_count') required int postLikesCount,
|
||||||
|
@JsonKey(name: 'post_comments_count') required int postCommentsCount,
|
||||||
|
@JsonKey(name: 'post_is_liked_by_user') required bool postIsLikedByUser,
|
||||||
|
@JsonKey(name: 'is_entity_subscribed_by_me') bool? isEntitySubscribedByMe,
|
||||||
|
@JsonKey(name: 'post_url') String? postUrl,
|
||||||
|
@JsonKey(name: 'post_state') required String postState,
|
||||||
|
@JsonKey(name: 'post_author_detailz') PostAuthorDetails? postAuthorDetails,
|
||||||
|
@JsonKey(name: "video_url_thumb") String? videoUrlThumb,
|
||||||
|
@JsonKey(name: 'created_at', fromJson: parseDateTime) required DateTime createdAt,
|
||||||
|
@JsonKey(name: 'updated_at', fromJson: parseDateTime) required DateTime updatedAt,
|
||||||
|
}) = _PostFeedItemModel;
|
||||||
|
|
||||||
|
factory PostFeedItemModel.fromJson(Map<String, Object?> json) => _$PostFeedItemModelFromJson(json);
|
||||||
|
}
|
||||||
|
|
||||||
|
@freezed
|
||||||
|
abstract class FeedGalleryImage with _$FeedGalleryImage {
|
||||||
|
const factory FeedGalleryImage({
|
||||||
|
required String url,
|
||||||
|
@Default('horizontal') String orientation,
|
||||||
|
int? id,
|
||||||
|
int? position,
|
||||||
|
}) = _FeedGalleryImage;
|
||||||
|
|
||||||
|
factory FeedGalleryImage.fromJson(Map<String, Object?> json) => _$FeedGalleryImageFromJson(json);
|
||||||
|
}
|
||||||
|
|
||||||
|
@freezed
|
||||||
|
abstract class PostAuthorDetails with _$PostAuthorDetails {
|
||||||
|
const factory PostAuthorDetails({
|
||||||
|
required String slug,
|
||||||
|
@JsonKey(name: 'is_consultant') required bool isConsultant,
|
||||||
|
}) = _PostAuthorDetails;
|
||||||
|
|
||||||
|
factory PostAuthorDetails.fromJson(Map<String, Object?> json) => _$PostAuthorDetailsFromJson(json);
|
||||||
|
}
|
||||||
|
|
||||||
|
@freezed
|
||||||
|
abstract class CursorLinks with _$CursorLinks {
|
||||||
|
const factory CursorLinks({
|
||||||
|
required String? first,
|
||||||
|
required String? last,
|
||||||
|
required String? prev,
|
||||||
|
required String? next,
|
||||||
|
}) = _CursorLinks;
|
||||||
|
|
||||||
|
factory CursorLinks.fromJson(Map<String, Object?> json) => _$CursorLinksFromJson(json);
|
||||||
|
}
|
||||||
|
|
||||||
|
@freezed
|
||||||
|
abstract class CursorMeta with _$CursorMeta {
|
||||||
|
const factory CursorMeta({
|
||||||
|
required String path,
|
||||||
|
@JsonKey(name: 'per_page') required int perPage,
|
||||||
|
@JsonKey(name: 'next_cursor') String? nextCursor,
|
||||||
|
@JsonKey(name: 'prev_cursor') String? prevCursor,
|
||||||
|
}) = _CursorMeta;
|
||||||
|
|
||||||
|
factory CursorMeta.fromJson(Map<String, Object?> json) => _$CursorMetaFromJson(json);
|
||||||
|
}
|
||||||
|
|
||||||
|
DateTime parseDateTime(String date) => DateTime.parse(date).toLocal();
|
||||||
@@ -8,8 +8,8 @@ import 'package:eatvio_app/core/error_reporting/report_from.dart';
|
|||||||
|
|
||||||
class FeedCubit extends Cubit<FeedState> {
|
class FeedCubit extends Cubit<FeedState> {
|
||||||
final FeedRepository feedRepository;
|
final FeedRepository feedRepository;
|
||||||
int currentPage = 1;
|
String? _nextCursor;
|
||||||
late int lastPage;
|
bool _hasMore = true;
|
||||||
bool isLoading = false;
|
bool isLoading = false;
|
||||||
String? _query;
|
String? _query;
|
||||||
|
|
||||||
@@ -38,13 +38,16 @@ class FeedCubit extends Cubit<FeedState> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> loadFeedBlogPostList({String? query}) async {
|
Future<void> loadFeedBlogPostList({String? query, int? currentUserId}) async {
|
||||||
try {
|
try {
|
||||||
_query = query;
|
_query = query;
|
||||||
final feedBlogPosts = await feedRepository.getFeedBlogPosts(query: query);
|
_nextCursor = null;
|
||||||
lastPage = feedBlogPosts.meta.lastPage;
|
_hasMore = true;
|
||||||
|
final feed = await feedRepository.getFeed(search: query, currentUserId: currentUserId);
|
||||||
|
_nextCursor = feed.nextCursor;
|
||||||
|
_hasMore = _nextCursor != null;
|
||||||
if (!isClosed) {
|
if (!isClosed) {
|
||||||
emit(FeedState.loaded(feedBlogPostList: feedBlogPosts.feedBlogPostList, isLoading: false));
|
emit(FeedState.loaded(feedBlogPostList: feed.posts, isLoading: false));
|
||||||
}
|
}
|
||||||
} catch (e, st) {
|
} catch (e, st) {
|
||||||
await reportFrom(e, st, this);
|
await reportFrom(e, st, this);
|
||||||
@@ -54,29 +57,35 @@ class FeedCubit extends Cubit<FeedState> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> loadNextPage() async {
|
Future<void> loadNextPage({int? currentUserId}) async {
|
||||||
if (currentPage < lastPage && !isLoading && !isClosed) {
|
if (!_hasMore || isLoading || isClosed || _nextCursor == null || state is! FeedStateLoaded) {
|
||||||
isLoading = true;
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!isClosed) {
|
isLoading = true;
|
||||||
emit((state as FeedStateLoaded).copyWith(isLoading: isLoading));
|
emit((state as FeedStateLoaded).copyWith(isLoading: true));
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (currentPage < lastPage && !isClosed) {
|
final feed = await feedRepository.getFeed(search: _query, cursor: _nextCursor, currentUserId: currentUserId);
|
||||||
currentPage++;
|
_nextCursor = feed.nextCursor;
|
||||||
final feedBlogPosts = await feedRepository.getFeedBlogPosts(page: currentPage, query: _query);
|
_hasMore = _nextCursor != null;
|
||||||
isLoading = false;
|
if (!isClosed && state is FeedStateLoaded) {
|
||||||
if (!isClosed) {
|
emit(
|
||||||
emit(FeedState.loaded(feedBlogPostList: [...(state as FeedStateLoaded).feedBlogPostList, ...feedBlogPosts.feedBlogPostList], isLoading: isLoading));
|
FeedState.loaded(
|
||||||
}
|
feedBlogPostList: [...(state as FeedStateLoaded).feedBlogPostList, ...feed.posts],
|
||||||
}
|
isLoading: false,
|
||||||
} catch (e, st) {
|
),
|
||||||
await reportFrom(e, st, this);
|
);
|
||||||
if (!isClosed) {
|
|
||||||
emit(FeedState.error(errorMessage: e.toString()));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
} catch (e, st) {
|
||||||
|
await reportFrom(e, st, this);
|
||||||
|
if (!isClosed && state is FeedStateLoaded) {
|
||||||
|
emit((state as FeedStateLoaded).copyWith(isLoading: false));
|
||||||
|
} else if (!isClosed) {
|
||||||
|
emit(FeedState.error(errorMessage: e.toString()));
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
isLoading = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ class _FeedScreenContentState extends State<FeedScreenContent> {
|
|||||||
final state = feedCubit.state;
|
final state = feedCubit.state;
|
||||||
// Загружаем данные, если они еще не загружены
|
// Загружаем данные, если они еще не загружены
|
||||||
if (state is! FeedStateLoaded) {
|
if (state is! FeedStateLoaded) {
|
||||||
feedCubit.loadFeedBlogPostList();
|
feedCubit.loadFeedBlogPostList(currentUserId: context.read<FeedProfileCubit>().state.profile?.id);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
super.initState();
|
super.initState();
|
||||||
@@ -201,7 +201,10 @@ class _FeedScreenContentState extends State<FeedScreenContent> {
|
|||||||
EasyDebounce.debounce('searchFeed', const Duration(milliseconds: 500), () {
|
EasyDebounce.debounce('searchFeed', const Duration(milliseconds: 500), () {
|
||||||
switch (_selectedChipIndex) {
|
switch (_selectedChipIndex) {
|
||||||
case 0:
|
case 0:
|
||||||
context.read<FeedCubit>().loadFeedBlogPostList(query: query);
|
context.read<FeedCubit>().loadFeedBlogPostList(
|
||||||
|
query: query,
|
||||||
|
currentUserId: context.read<FeedProfileCubit>().state.profile?.id,
|
||||||
|
);
|
||||||
break;
|
break;
|
||||||
case 1 when !hidePlans:
|
case 1 when !hidePlans:
|
||||||
context.read<PlansCubit>().loadPlans(query: query);
|
context.read<PlansCubit>().loadPlans(query: query);
|
||||||
@@ -224,7 +227,9 @@ class _FeedScreenContentState extends State<FeedScreenContent> {
|
|||||||
EasyDebounce.debounce('searchFeed', const Duration(milliseconds: 500), () {
|
EasyDebounce.debounce('searchFeed', const Duration(milliseconds: 500), () {
|
||||||
switch (_selectedChipIndex) {
|
switch (_selectedChipIndex) {
|
||||||
case 0:
|
case 0:
|
||||||
context.read<FeedCubit>().loadFeedBlogPostList();
|
context.read<FeedCubit>().loadFeedBlogPostList(
|
||||||
|
currentUserId: context.read<FeedProfileCubit>().state.profile?.id,
|
||||||
|
);
|
||||||
break;
|
break;
|
||||||
case 1 when !hidePlans:
|
case 1 when !hidePlans:
|
||||||
context.read<PlansCubit>().loadPlans();
|
context.read<PlansCubit>().loadPlans();
|
||||||
|
|||||||
@@ -0,0 +1,287 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
|
import 'package:carousel_slider_plus/carousel_slider_plus.dart';
|
||||||
|
import 'package:eatvio_app/features/feed/models/post_feed_mapper.dart';
|
||||||
|
import 'package:eatvio_app/features/feed/models/post_feed_pagination_model.dart';
|
||||||
|
import 'package:eatvio_app/features/feed/presentation/widgets/feed_video_player/feed_video_player_widget.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
/// Gallery + optional embed video for feed card and blog post detail.
|
||||||
|
class BlogPostMediaContent extends StatefulWidget {
|
||||||
|
const BlogPostMediaContent({
|
||||||
|
super.key,
|
||||||
|
required this.galleryImages,
|
||||||
|
required this.imageUrl,
|
||||||
|
required this.videoEmbedUrl,
|
||||||
|
required this.currentGalleryIndex,
|
||||||
|
required this.onOpenGallery,
|
||||||
|
required this.onGalleryChanged,
|
||||||
|
required this.videoUrlThumb,
|
||||||
|
this.heroTag,
|
||||||
|
});
|
||||||
|
|
||||||
|
final List<FeedGalleryImage> galleryImages;
|
||||||
|
final String imageUrl;
|
||||||
|
final String? videoEmbedUrl;
|
||||||
|
final String? videoUrlThumb;
|
||||||
|
final int currentGalleryIndex;
|
||||||
|
final void Function(List<String> urls, int index) onOpenGallery;
|
||||||
|
final ValueChanged<int> onGalleryChanged;
|
||||||
|
|
||||||
|
/// Unique per post. Required when several cards with the same [imageUrl] share a route.
|
||||||
|
final Object? heroTag;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<BlogPostMediaContent> createState() => _BlogPostMediaContentState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _BlogPostMediaContentState extends State<BlogPostMediaContent> {
|
||||||
|
static const _counterDisplayDuration = Duration(seconds: 1);
|
||||||
|
|
||||||
|
/// Landscape / video slide aspect (width / height).
|
||||||
|
static const _horizontalAspectRatio = 16 / 9;
|
||||||
|
|
||||||
|
/// Portrait slide aspect (width / height).
|
||||||
|
static const _verticalAspectRatio = 3 / 4;
|
||||||
|
|
||||||
|
bool _isCounterVisible = false;
|
||||||
|
Timer? _counterHideTimer;
|
||||||
|
|
||||||
|
bool get _hasVideo =>
|
||||||
|
widget.videoEmbedUrl != null && widget.videoEmbedUrl!.trim().isNotEmpty;
|
||||||
|
|
||||||
|
List<FeedGalleryImage> get _gallery {
|
||||||
|
return [
|
||||||
|
for (final image in widget.galleryImages)
|
||||||
|
if (PostFeedMapper.resolveMediaUrl(image.url) case final url
|
||||||
|
when url.isNotEmpty)
|
||||||
|
FeedGalleryImage(
|
||||||
|
url: url,
|
||||||
|
orientation: image.orientation,
|
||||||
|
id: image.id,
|
||||||
|
position: image.position,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
List<String> get _galleryUrls => _gallery.map((e) => e.url).toList();
|
||||||
|
|
||||||
|
int get _slideCount {
|
||||||
|
if (_gallery.isNotEmpty) {
|
||||||
|
return _gallery.length + (_hasVideo ? 1 : 0);
|
||||||
|
}
|
||||||
|
if (_hasVideo) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool get _isCurrentSlideVideo =>
|
||||||
|
_hasVideo && widget.currentGalleryIndex == _gallery.length;
|
||||||
|
|
||||||
|
bool get _shouldShowCounter => !_isCurrentSlideVideo || _isCounterVisible;
|
||||||
|
|
||||||
|
/// Height is fixed for the whole post from the first gallery image (web-like).
|
||||||
|
String get _postOrientation {
|
||||||
|
if (_gallery.isNotEmpty) {
|
||||||
|
return _gallery.first.orientation;
|
||||||
|
}
|
||||||
|
return 'horizontal';
|
||||||
|
}
|
||||||
|
|
||||||
|
double _heightForOrientation(String orientation, double width) {
|
||||||
|
final aspectRatio = orientation == 'vertical'
|
||||||
|
? _verticalAspectRatio
|
||||||
|
: _horizontalAspectRatio;
|
||||||
|
final maxHeight = MediaQuery.sizeOf(context).height * 0.7;
|
||||||
|
return (width / aspectRatio).clamp(180.0, maxHeight);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_counterHideTimer?.cancel();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didUpdateWidget(covariant BlogPostMediaContent oldWidget) {
|
||||||
|
super.didUpdateWidget(oldWidget);
|
||||||
|
if (oldWidget.currentGalleryIndex != widget.currentGalleryIndex) {
|
||||||
|
_scheduleCounterHideOnVideo();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _scheduleCounterHideOnVideo() {
|
||||||
|
_counterHideTimer?.cancel();
|
||||||
|
if (!_isCurrentSlideVideo) return;
|
||||||
|
|
||||||
|
_counterHideTimer = Timer(_counterDisplayDuration, () {
|
||||||
|
if (mounted && _isCurrentSlideVideo) {
|
||||||
|
setState(() => _isCounterVisible = false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _handleCarouselScroll(ScrollNotification notification) {
|
||||||
|
if (notification is ScrollStartNotification ||
|
||||||
|
notification is ScrollUpdateNotification) {
|
||||||
|
_counterHideTimer?.cancel();
|
||||||
|
if (!_isCounterVisible) {
|
||||||
|
setState(() => _isCounterVisible = true);
|
||||||
|
}
|
||||||
|
} else if (notification is ScrollEndNotification &&
|
||||||
|
_isCounterVisible &&
|
||||||
|
_isCurrentSlideVideo) {
|
||||||
|
_scheduleCounterHideOnVideo();
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _imageSlide(String itemUrl, int itemIndex, double height) {
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: () => widget.onOpenGallery(_galleryUrls, itemIndex),
|
||||||
|
child: CachedNetworkImage(
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
width: MediaQuery.sizeOf(context).width,
|
||||||
|
height: height,
|
||||||
|
imageUrl: itemUrl,
|
||||||
|
errorWidget: (context, str, object) => const SizedBox.shrink(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _videoSlide() {
|
||||||
|
return FeedVideoPlayer(
|
||||||
|
embedUrl: widget.videoEmbedUrl!,
|
||||||
|
videoUrlThumb: widget.videoUrlThumb,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _carousel({
|
||||||
|
required int itemCount,
|
||||||
|
required NullableIndexedWidgetBuilder itemBuilder,
|
||||||
|
required double height,
|
||||||
|
}) {
|
||||||
|
return Stack(
|
||||||
|
children: [
|
||||||
|
NotificationListener<ScrollNotification>(
|
||||||
|
onNotification: _handleCarouselScroll,
|
||||||
|
child: CarouselSlider.builder(
|
||||||
|
itemCount: itemCount,
|
||||||
|
itemBuilder: (context, itemIndex, _) =>
|
||||||
|
itemBuilder(context, itemIndex) ?? const SizedBox.shrink(),
|
||||||
|
options: CarouselOptions(
|
||||||
|
viewportFraction: 1,
|
||||||
|
height: height,
|
||||||
|
enableInfiniteScroll: false,
|
||||||
|
onPageChanged: (index, _) => widget.onGalleryChanged(index),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (itemCount > 1)
|
||||||
|
Positioned(
|
||||||
|
top: 12,
|
||||||
|
right: 12,
|
||||||
|
child: IgnorePointer(
|
||||||
|
child: AnimatedOpacity(
|
||||||
|
opacity: _shouldShowCounter ? 1 : 0,
|
||||||
|
duration: const Duration(milliseconds: 150),
|
||||||
|
child: _GalleryPhotoCounter(
|
||||||
|
currentIndex: widget.currentGalleryIndex,
|
||||||
|
total: itemCount,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final gallery = _gallery;
|
||||||
|
final width = MediaQuery.sizeOf(context).width;
|
||||||
|
final mediaHeight = _heightForOrientation(_postOrientation, width);
|
||||||
|
|
||||||
|
if (gallery.length > 1 || (gallery.length == 1 && _hasVideo)) {
|
||||||
|
final itemCount = _slideCount;
|
||||||
|
return _carousel(
|
||||||
|
itemCount: itemCount,
|
||||||
|
height: mediaHeight,
|
||||||
|
itemBuilder: (context, itemIndex) {
|
||||||
|
if (itemIndex < gallery.length) {
|
||||||
|
return _imageSlide(gallery[itemIndex].url, itemIndex, mediaHeight);
|
||||||
|
}
|
||||||
|
if (_hasVideo) {
|
||||||
|
return _videoSlide();
|
||||||
|
}
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (gallery.length == 1) {
|
||||||
|
return _imageSlide(gallery.first.url, 0, mediaHeight);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_hasVideo) {
|
||||||
|
return SizedBox(height: mediaHeight, width: width, child: _videoSlide());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (widget.imageUrl.isNotEmpty) {
|
||||||
|
final image = CachedNetworkImage(
|
||||||
|
fit: BoxFit.fitWidth,
|
||||||
|
width: width,
|
||||||
|
imageUrl: widget.imageUrl,
|
||||||
|
errorWidget: (context, str, object) => const SizedBox.shrink(),
|
||||||
|
);
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: () => widget.onOpenGallery([widget.imageUrl], 0),
|
||||||
|
child: widget.heroTag == null
|
||||||
|
? image
|
||||||
|
: Hero(tag: widget.heroTag!, child: image),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _GalleryPhotoCounter extends StatelessWidget {
|
||||||
|
const _GalleryPhotoCounter({required this.currentIndex, required this.total});
|
||||||
|
|
||||||
|
final int currentIndex;
|
||||||
|
final int total;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.black.withValues(alpha: 0.5),
|
||||||
|
borderRadius: BorderRadius.circular(999),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const Icon(
|
||||||
|
Icons.photo_library_outlined,
|
||||||
|
size: 14,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
'${currentIndex + 1} / $total',
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
height: 1.2,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
import 'package:cached_network_image/cached_network_image.dart';
|
|
||||||
import 'package:dismissible_page/dismissible_page.dart';
|
import 'package:dismissible_page/dismissible_page.dart';
|
||||||
import 'package:eatvio_app/core/app_config.dart';
|
import 'package:eatvio_app/core/app_config.dart';
|
||||||
import 'package:eatvio_app/core/enums/complained_content.dart';
|
import 'package:eatvio_app/core/enums/complained_content.dart';
|
||||||
@@ -9,8 +8,10 @@ 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/feed_icon_button.dart';
|
||||||
import 'package:eatvio_app/core/widgets/markdown_html_widget.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/chat/presentation/pages/photo_view_screen.dart';
|
||||||
|
import 'package:eatvio_app/features/feed/models/feed_blog_post_model.dart';
|
||||||
|
import 'package:eatvio_app/features/feed/models/post_feed_mapper.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_cubit.dart';
|
||||||
import 'package:eatvio_app/features/feed/presentation/bloc/feed_cubit/feed_state.dart';
|
import 'package:eatvio_app/features/feed/presentation/widgets/blog_post_media_content.dart';
|
||||||
import 'package:eatvio_app/features/feed/presentation/widgets/subscribe_button_widget.dart';
|
import 'package:eatvio_app/features/feed/presentation/widgets/subscribe_button_widget.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
@@ -19,9 +20,9 @@ import 'package:go_router/go_router.dart';
|
|||||||
import 'package:share_plus/share_plus.dart';
|
import 'package:share_plus/share_plus.dart';
|
||||||
|
|
||||||
class BlogPostWidget extends StatefulWidget {
|
class BlogPostWidget extends StatefulWidget {
|
||||||
final int index;
|
final FeedBlogPostModel post;
|
||||||
|
|
||||||
const BlogPostWidget({super.key, required this.index});
|
const BlogPostWidget({super.key, required this.post});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<BlogPostWidget> createState() => _BlogPostWidgetState();
|
State<BlogPostWidget> createState() => _BlogPostWidgetState();
|
||||||
@@ -30,243 +31,343 @@ class BlogPostWidget extends StatefulWidget {
|
|||||||
class _BlogPostWidgetState extends State<BlogPostWidget> {
|
class _BlogPostWidgetState extends State<BlogPostWidget> {
|
||||||
late bool isLiked;
|
late bool isLiked;
|
||||||
late int totalLikes;
|
late int totalLikes;
|
||||||
bool isMoreOptionsVisible = false;
|
int currentGalleryIndex = 0;
|
||||||
|
|
||||||
|
FeedBlogPostModel get post => widget.post;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
isLiked = post.wasLikedByMe;
|
||||||
|
totalLikes = post.totalLikes;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didUpdateWidget(covariant BlogPostWidget oldWidget) {
|
||||||
|
super.didUpdateWidget(oldWidget);
|
||||||
|
if (oldWidget.post.id != post.id) {
|
||||||
|
currentGalleryIndex = 0;
|
||||||
|
isLiked = post.wasLikedByMe;
|
||||||
|
totalLikes = post.totalLikes;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (oldWidget.post.wasLikedByMe != post.wasLikedByMe ||
|
||||||
|
oldWidget.post.totalLikes != post.totalLikes) {
|
||||||
|
isLiked = post.wasLikedByMe;
|
||||||
|
totalLikes = post.totalLikes;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _openGallery(List<String> imageUrls, int index, {Object? heroTag}) {
|
||||||
|
context.pushTransparentRoute(
|
||||||
|
rootNavigator: true,
|
||||||
|
PhotoViewScreen(imageUrls: imageUrls, index: index, heroTag: heroTag),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _toggleLike() async {
|
||||||
|
final nextLiked = !isLiked;
|
||||||
|
setState(() {
|
||||||
|
isLiked = nextLiked;
|
||||||
|
nextLiked ? totalLikes++ : totalLikes--;
|
||||||
|
});
|
||||||
|
context.read<FeedCubit>().updateFeedBlogPostList(post, nextLiked);
|
||||||
|
if (nextLiked) {
|
||||||
|
await context.read<FeedCubit>().likePost(post.id);
|
||||||
|
} else {
|
||||||
|
await context.read<FeedCubit>().unlikedPost(post.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return BlocBuilder<FeedCubit, FeedState>(
|
final imageUrl = PostFeedMapper.resolveMediaUrl(post.image);
|
||||||
builder: (BuildContext context, FeedState state) {
|
final embedUrl = post.videoEmbedUrl?.trim();
|
||||||
if (state is FeedStateLoaded) {
|
|
||||||
isLiked = state.feedBlogPostList[widget.index].wasLikedByMe;
|
|
||||||
totalLikes = state.feedBlogPostList[widget.index].totalLikes;
|
|
||||||
return InkWell(
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
onTap: () => GoRouter.of(context).pushNamed('blogPostScreen', extra: state.feedBlogPostList[widget.index].id),
|
|
||||||
child: Material(
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
color: Colors.white,
|
|
||||||
child: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
|
||||||
child: Row(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
GestureDetector(
|
|
||||||
onTap: () {
|
|
||||||
GoRouter.of(context).pushNamed('profileScreen', extra: state.feedBlogPostList[widget.index].author.authorSlug);
|
|
||||||
},
|
|
||||||
child: CircleAvatar(
|
|
||||||
radius: 20,
|
|
||||||
backgroundColor: Colors.black12,
|
|
||||||
foregroundImage: NetworkImage(
|
|
||||||
state.feedBlogPostList[widget.index].author.authorProfileIcon.contains('https')
|
|
||||||
? state.feedBlogPostList[widget.index].author.authorProfileIcon
|
|
||||||
: sl<AppConfig>().baseUrl + state.feedBlogPostList[widget.index].author.authorProfileIcon,
|
|
||||||
),
|
|
||||||
backgroundImage: const AssetImage('assets/images/no_avatar.png'),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
// Имя и дата внутри Expanded
|
|
||||||
Expanded(
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
state.feedBlogPostList[widget.index].author.authorName,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
style: const TextStyle(height: 1, color: Colors.black87, fontWeight: FontWeight.w500, fontSize: 16),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 4),
|
|
||||||
Text(
|
|
||||||
'${calculateDifference(state.feedBlogPostList[widget.index].createdAt)} в ${state.feedBlogPostList[widget.index].createdAt.hour.toString().padLeft(2, '0')}:${state.feedBlogPostList[widget.index].createdAt.minute.toString().padLeft(2, '0')}',
|
|
||||||
style: const TextStyle(height: 1, color: Colors.black45, fontSize: 14),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
// Кнопка подписки (если нужно)
|
|
||||||
if (!(state.feedBlogPostList[widget.index].author.isEntitySubscribedByMe ?? false) &&
|
|
||||||
!state.feedBlogPostList[widget.index].author.authorIsMe)
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.only(right: 8),
|
|
||||||
child: SubscribeButtonWidget(slug: state.feedBlogPostList[widget.index].author.authorSlug),
|
|
||||||
),
|
|
||||||
// Кнопка меню
|
|
||||||
CustomPopup(
|
|
||||||
isAuthor: state.feedBlogPostList[widget.index].author.authorIsMe,
|
|
||||||
complainedContentType: ComplainedContent.posts,
|
|
||||||
path: 'blog_post/${(state.feedBlogPostList[widget.index].id)}',
|
|
||||||
id: state.feedBlogPostList[widget.index].id,
|
|
||||||
feedBlogPostModel: state.feedBlogPostList[widget.index],
|
|
||||||
userSlug: state.feedBlogPostList[widget.index].author.authorSlug,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (state.feedBlogPostList[widget.index].title.isNotEmpty) ...[
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
|
||||||
child: Text(
|
|
||||||
state.feedBlogPostList[widget.index].title,
|
|
||||||
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: Colors.black87),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
if (state.feedBlogPostList[widget.index].title.isEmpty) const SizedBox(height: 8),
|
|
||||||
if (state.feedBlogPostList[widget.index].title.isNotEmpty && state.feedBlogPostList[widget.index].description.isNotEmpty)
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
if (state.feedBlogPostList[widget.index].description.isNotEmpty)
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
|
||||||
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(
|
return InkWell(
|
||||||
markdownText: description,
|
borderRadius: BorderRadius.circular(12),
|
||||||
);
|
onTap: () =>
|
||||||
},
|
GoRouter.of(context).pushNamed('blogPostScreen', extra: post.id),
|
||||||
),
|
child: Material(
|
||||||
),
|
borderRadius: BorderRadius.circular(12),
|
||||||
if (state.feedBlogPostList[widget.index].description.length > 200)
|
color: Colors.white,
|
||||||
const Padding(
|
child: Column(
|
||||||
padding: EdgeInsets.symmetric(horizontal: 12),
|
mainAxisSize: MainAxisSize.min,
|
||||||
child: Text(
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
'Показать ещё',
|
children: [
|
||||||
style: TextStyle(height: 1, color: Color(0xFF8B8B8B), fontSize: 14, fontWeight: FontWeight.w600),
|
const SizedBox(height: 12),
|
||||||
),
|
_BlogPostHeader(post: post),
|
||||||
),
|
_BlogPostTextContent(post: post),
|
||||||
if (state.feedBlogPostList[widget.index].title.isNotEmpty || state.feedBlogPostList[widget.index].description.isNotEmpty)
|
BlogPostMediaContent(
|
||||||
const SizedBox(height: 8),
|
galleryImages: post.images,
|
||||||
GestureDetector(
|
imageUrl: imageUrl,
|
||||||
onTap: () {
|
videoEmbedUrl: embedUrl != null && embedUrl.isNotEmpty
|
||||||
context.pushTransparentRoute(
|
? embedUrl
|
||||||
rootNavigator: true,
|
: null,
|
||||||
PhotoViewScreen(imageUrls: ['${sl<AppConfig>().baseUrl}/storage/${state.feedBlogPostList[widget.index].image}'], index: 0),
|
videoUrlThumb: post.videoUrlThumb,
|
||||||
);
|
currentGalleryIndex: currentGalleryIndex,
|
||||||
},
|
heroTag: 'feed-blog-post-${post.id}-cover',
|
||||||
child: Hero(
|
onOpenGallery: (urls, index) => _openGallery(
|
||||||
tag: '${sl<AppConfig>().baseUrl}/storage/${state.feedBlogPostList[widget.index].image}',
|
urls,
|
||||||
child: CachedNetworkImage(
|
index,
|
||||||
fit: BoxFit.fitWidth,
|
heroTag: urls.length == 1
|
||||||
width: MediaQuery.of(context).size.width,
|
? 'feed-blog-post-${post.id}-cover'
|
||||||
imageUrl: '${sl<AppConfig>().baseUrl}/storage/${state.feedBlogPostList[widget.index].image}',
|
: null,
|
||||||
errorWidget: (context, str, object) => const SizedBox.shrink(),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
FeedIconButton(
|
|
||||||
icon: isLiked ? Icons.favorite_rounded : Icons.favorite_border_rounded,
|
|
||||||
text: totalLikes.toString(),
|
|
||||||
iconColor: isLiked ? Colors.red.withValues(alpha: 0.8) : null,
|
|
||||||
backgroundColor: isLiked ? Colors.red.withValues(alpha: 0.1) : null,
|
|
||||||
textColor: isLiked ? Colors.red.withValues(alpha: 0.8) : null,
|
|
||||||
onTap: () async {
|
|
||||||
setState(() {
|
|
||||||
isLiked = !isLiked;
|
|
||||||
isLiked ? totalLikes++ : totalLikes--;
|
|
||||||
});
|
|
||||||
// Update feed list immediately by ID (no reload)
|
|
||||||
context.read<FeedCubit>().updateFeedBlogPostList(state.feedBlogPostList[widget.index], isLiked);
|
|
||||||
// Fire network call without introducing UI delay
|
|
||||||
if (isLiked) {
|
|
||||||
await context.read<FeedCubit>().likePost(state.feedBlogPostList[widget.index].id);
|
|
||||||
} else {
|
|
||||||
await context.read<FeedCubit>().unlikedPost(state.feedBlogPostList[widget.index].id);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
FeedIconButton(
|
|
||||||
svgIcon: SvgPicture.asset(
|
|
||||||
'assets/icons/comment.svg',
|
|
||||||
height: 20,
|
|
||||||
width: 20,
|
|
||||||
colorFilter: ColorFilter.mode(appTheme.gray600, BlendMode.srcIn),
|
|
||||||
),
|
|
||||||
text: state.feedBlogPostList[widget.index].totalComments.toString(),
|
|
||||||
onTap: () => GoRouter.of(context).pushNamed('blogPostScreen', extra: state.feedBlogPostList[widget.index].id),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
FeedIconButton(
|
|
||||||
svgIcon: SvgPicture.asset(
|
|
||||||
height: 15,
|
|
||||||
width: 12,
|
|
||||||
'assets/icons/repost.svg',
|
|
||||||
colorFilter: ColorFilter.mode(appTheme.gray600, BlendMode.srcIn),
|
|
||||||
),
|
|
||||||
onTap: () {
|
|
||||||
final box = context.findRenderObject() as RenderBox?;
|
|
||||||
SharePlus.instance.share(
|
|
||||||
ShareParams(
|
|
||||||
uri: Uri.parse('${sl<AppConfig>().baseUrl}/ru/blog_post/${state.feedBlogPostList[widget.index].id}'),
|
|
||||||
sharePositionOrigin: box!.localToGlobal(Offset.zero) & box.size,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
const Spacer(),
|
|
||||||
Icon(Icons.visibility_rounded, size: 16, color: appTheme.gray500),
|
|
||||||
const SizedBox(width: 4),
|
|
||||||
Text(
|
|
||||||
state.feedBlogPostList[widget.index].totalViews.toString(),
|
|
||||||
style: TextStyle(fontSize: 12, color: appTheme.gray500, fontWeight: FontWeight.w400),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
|
onGalleryChanged: (index) {
|
||||||
|
setState(() {
|
||||||
|
currentGalleryIndex = index;
|
||||||
|
});
|
||||||
|
},
|
||||||
),
|
),
|
||||||
);
|
const SizedBox(height: 12),
|
||||||
}
|
_BlogPostActionsRow(
|
||||||
return const SizedBox.shrink();
|
post: post,
|
||||||
},
|
isLiked: isLiked,
|
||||||
|
totalLikes: totalLikes,
|
||||||
|
onToggleLike: _toggleLike,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _BlogPostHeader extends StatelessWidget {
|
||||||
|
const _BlogPostHeader({required this.post});
|
||||||
|
|
||||||
|
final FeedBlogPostModel post;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
GestureDetector(
|
||||||
|
onTap: () => GoRouter.of(
|
||||||
|
context,
|
||||||
|
).pushNamed('profileScreen', extra: post.author.authorSlug),
|
||||||
|
child: CircleAvatar(
|
||||||
|
radius: 20,
|
||||||
|
backgroundColor: Colors.black12,
|
||||||
|
foregroundImage: post.author.authorProfileIcon.isNotEmpty
|
||||||
|
? NetworkImage(
|
||||||
|
PostFeedMapper.resolveMediaUrl(
|
||||||
|
post.author.authorProfileIcon,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
backgroundImage: const AssetImage('assets/images/no_avatar.png'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
post.author.authorName,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: const TextStyle(
|
||||||
|
height: 1,
|
||||||
|
color: Colors.black87,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
fontSize: 16,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
'${calculateDifference(post.createdAt)} в ${post.createdAt.hour.toString().padLeft(2, '0')}:${post.createdAt.minute.toString().padLeft(2, '0')}',
|
||||||
|
style: const TextStyle(
|
||||||
|
height: 1,
|
||||||
|
color: Colors.black45,
|
||||||
|
fontSize: 14,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (!(post.author.isEntitySubscribedByMe ?? false) &&
|
||||||
|
!post.author.authorIsMe)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(right: 8),
|
||||||
|
child: SubscribeButtonWidget(slug: post.author.authorSlug),
|
||||||
|
),
|
||||||
|
CustomPopup(
|
||||||
|
isAuthor: post.author.authorIsMe,
|
||||||
|
complainedContentType: ComplainedContent.posts,
|
||||||
|
path: 'blog_post/${post.id}',
|
||||||
|
id: post.id,
|
||||||
|
feedBlogPostModel: post,
|
||||||
|
userSlug: post.author.authorSlug,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _BlogPostTextContent extends StatelessWidget {
|
||||||
|
const _BlogPostTextContent({required this.post});
|
||||||
|
|
||||||
|
final FeedBlogPostModel post;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
if (post.title.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||||
|
child: Text(
|
||||||
|
post.title,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: Colors.black87,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
if (post.title.isEmpty) const SizedBox(height: 8),
|
||||||
|
if (post.title.isNotEmpty && post.description.isNotEmpty)
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
if (post.description.isNotEmpty)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||||
|
child: MarkdownHtmlWidget(
|
||||||
|
markdownText: _truncateDescription(post.description),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (post.description.length > 200)
|
||||||
|
const Padding(
|
||||||
|
padding: EdgeInsets.symmetric(horizontal: 12),
|
||||||
|
child: Text(
|
||||||
|
'Показать ещё',
|
||||||
|
style: TextStyle(
|
||||||
|
height: 1,
|
||||||
|
color: Color(0xFF8B8B8B),
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (post.title.isNotEmpty || post.description.isNotEmpty)
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _BlogPostActionsRow extends StatelessWidget {
|
||||||
|
const _BlogPostActionsRow({
|
||||||
|
required this.post,
|
||||||
|
required this.isLiked,
|
||||||
|
required this.totalLikes,
|
||||||
|
required this.onToggleLike,
|
||||||
|
});
|
||||||
|
|
||||||
|
final FeedBlogPostModel post;
|
||||||
|
final bool isLiked;
|
||||||
|
final int totalLikes;
|
||||||
|
final Future<void> Function() onToggleLike;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
FeedIconButton(
|
||||||
|
icon: isLiked
|
||||||
|
? Icons.favorite_rounded
|
||||||
|
: Icons.favorite_border_rounded,
|
||||||
|
text: totalLikes.toString(),
|
||||||
|
iconColor: isLiked ? Colors.red.withValues(alpha: 0.8) : null,
|
||||||
|
backgroundColor: isLiked ? Colors.red.withValues(alpha: 0.1) : null,
|
||||||
|
textColor: isLiked ? Colors.red.withValues(alpha: 0.8) : null,
|
||||||
|
onTap: onToggleLike,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
FeedIconButton(
|
||||||
|
svgIcon: SvgPicture.asset(
|
||||||
|
'assets/icons/comment.svg',
|
||||||
|
height: 20,
|
||||||
|
width: 20,
|
||||||
|
colorFilter: ColorFilter.mode(appTheme.gray600, BlendMode.srcIn),
|
||||||
|
),
|
||||||
|
text: post.totalComments.toString(),
|
||||||
|
onTap: () => GoRouter.of(
|
||||||
|
context,
|
||||||
|
).pushNamed('blogPostScreen', extra: post.id),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
FeedIconButton(
|
||||||
|
svgIcon: SvgPicture.asset(
|
||||||
|
height: 15,
|
||||||
|
width: 12,
|
||||||
|
'assets/icons/repost.svg',
|
||||||
|
colorFilter: ColorFilter.mode(appTheme.gray600, BlendMode.srcIn),
|
||||||
|
),
|
||||||
|
onTap: () {
|
||||||
|
final box = context.findRenderObject() as RenderBox?;
|
||||||
|
SharePlus.instance.share(
|
||||||
|
ShareParams(
|
||||||
|
uri: Uri.parse(
|
||||||
|
'${sl<AppConfig>().baseUrl}/ru/blog_post/${post.id}',
|
||||||
|
),
|
||||||
|
sharePositionOrigin:
|
||||||
|
box!.localToGlobal(Offset.zero) & box.size,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
Icon(Icons.visibility_rounded, size: 16, color: appTheme.gray500),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Text(
|
||||||
|
post.totalViews.toString(),
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: appTheme.gray500,
|
||||||
|
fontWeight: FontWeight.w400,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _truncateDescription(String description) {
|
||||||
|
final isLong = description.length > 200;
|
||||||
|
if (!isLong) return description;
|
||||||
|
|
||||||
|
var cutPoint = 200;
|
||||||
|
final preview = description.substring(0, cutPoint);
|
||||||
|
final boldOpen = (preview.split('**').length - 1) ~/ 2;
|
||||||
|
final boldClose = preview.split('**').length - 1 - boldOpen * 2;
|
||||||
|
final hasUnclosedBold = boldOpen > boldClose;
|
||||||
|
|
||||||
|
if (hasUnclosedBold) {
|
||||||
|
final searchEnd = (cutPoint + 100).clamp(0, description.length);
|
||||||
|
final 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
return description.substring(0, cutPoint);
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import 'package:eatvio_app/core/widgets/data_error_widget.dart';
|
|||||||
import 'package:eatvio_app/core/widgets/loader.dart';
|
import 'package:eatvio_app/core/widgets/loader.dart';
|
||||||
import 'package:eatvio_app/features/feed/presentation/bloc/feed_cubit/feed_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/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/pages/feed_screen.dart';
|
import 'package:eatvio_app/features/feed/presentation/pages/feed_screen.dart';
|
||||||
import 'package:eatvio_app/features/feed/presentation/widgets/blog_post_widget.dart';
|
import 'package:eatvio_app/features/feed/presentation/widgets/blog_post_widget.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
@@ -11,18 +12,18 @@ import 'package:visibility_detector/visibility_detector.dart';
|
|||||||
|
|
||||||
class BlogPosts extends StatelessWidget {
|
class BlogPosts extends StatelessWidget {
|
||||||
const BlogPosts({super.key, required this.handleVisibility});
|
const BlogPosts({super.key, required this.handleVisibility});
|
||||||
final Function({required double visibleFraction,required int id, required ViewableContentType type}) handleVisibility;
|
final Function({required double visibleFraction, required int id, required ViewableContentType type}) handleVisibility;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return RefreshIndicator(
|
return RefreshIndicator(
|
||||||
onRefresh: () => context.read<FeedCubit>().loadFeedBlogPostList(),
|
onRefresh: () => context.read<FeedCubit>().loadFeedBlogPostList(currentUserId: context.read<FeedProfileCubit>().state.profile?.id),
|
||||||
child: BlocBuilder<FeedCubit, FeedState>(
|
child: BlocBuilder<FeedCubit, FeedState>(
|
||||||
builder: (BuildContext context, FeedState state) {
|
builder: (BuildContext context, FeedState state) {
|
||||||
if (state is FeedStateLoaded) {
|
if (state is FeedStateLoaded) {
|
||||||
return NotificationListener<ScrollNotification>(
|
return NotificationListener<ScrollNotification>(
|
||||||
onNotification: (notification) {
|
onNotification: (notification) {
|
||||||
loadNewContent(notification, context.read<FeedCubit>().loadNextPage);
|
loadNewContent(notification, () => context.read<FeedCubit>().loadNextPage(currentUserId: context.read<FeedProfileCubit>().state.profile?.id));
|
||||||
return false;
|
return false;
|
||||||
},
|
},
|
||||||
child: ListView.separated(
|
child: ListView.separated(
|
||||||
@@ -32,13 +33,13 @@ class BlogPosts extends StatelessWidget {
|
|||||||
if (index == state.feedBlogPostList.length) {
|
if (index == state.feedBlogPostList.length) {
|
||||||
return const Loader();
|
return const Loader();
|
||||||
}
|
}
|
||||||
|
final post = state.feedBlogPostList[index];
|
||||||
return VisibilityDetector(
|
return VisibilityDetector(
|
||||||
|
key: ValueKey(post.id),
|
||||||
onVisibilityChanged: (info) {
|
onVisibilityChanged: (info) {
|
||||||
final id = state.feedBlogPostList[index].id;
|
handleVisibility(visibleFraction: info.visibleFraction, id: post.id, type: ViewableContentType.posts);
|
||||||
handleVisibility(visibleFraction: info.visibleFraction, id: id, type: ViewableContentType.posts);
|
|
||||||
},
|
},
|
||||||
key: ValueKey(index),
|
child: BlogPostWidget(post: post),
|
||||||
child: BlogPostWidget(index: index),
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
separatorBuilder: (context, index) => const SizedBox(height: 8),
|
separatorBuilder: (context, index) => const SizedBox(height: 8),
|
||||||
@@ -46,7 +47,9 @@ class BlogPosts extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
} else if (state is FeedStateError) {
|
} else if (state is FeedStateError) {
|
||||||
return DataErrorWidget(onReload: () => context.read<FeedCubit>().loadFeedBlogPostList());
|
return DataErrorWidget(
|
||||||
|
onReload: () => context.read<FeedCubit>().loadFeedBlogPostList(currentUserId: context.read<FeedProfileCubit>().state.profile?.id),
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
return Scaffold(backgroundColor: Colors.grey.shade200, body: const Loader());
|
return Scaffold(backgroundColor: Colors.grey.shade200, body: const Loader());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:eatvio_app/features/feed/presentation/widgets/feed_video_player/feed_video_embed_scripts.dart';
|
||||||
|
|
||||||
|
enum FeedVideoEmbedProvider { vk, other }
|
||||||
|
|
||||||
|
/// Сборка URL и HTML для embed-плеера ленты.
|
||||||
|
abstract final class FeedVideoEmbed {
|
||||||
|
static FeedVideoEmbedProvider detectProvider(Uri uri) {
|
||||||
|
final host = uri.host.toLowerCase();
|
||||||
|
final isVk = host == 'vk.com' ||
|
||||||
|
host.endsWith('.vk.com') ||
|
||||||
|
host == 'vk.ru' ||
|
||||||
|
host.endsWith('.vk.ru') ||
|
||||||
|
host == 'vkvideo.ru' ||
|
||||||
|
host.endsWith('.vkvideo.ru');
|
||||||
|
return isVk ? FeedVideoEmbedProvider.vk : FeedVideoEmbedProvider.other;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// После жеста пользователя включаем autoplay, чтобы стартнуть сразу после загрузки.
|
||||||
|
static Uri uriForPlayback(String url) {
|
||||||
|
final uri = Uri.parse(url);
|
||||||
|
final params = Map<String, String>.from(uri.queryParameters);
|
||||||
|
params['autoplay'] = '1';
|
||||||
|
params['mute'] = '1';
|
||||||
|
params.putIfAbsent('playsinline', () => '1');
|
||||||
|
|
||||||
|
// VK Video JS API: без js_api=1 события/методы плеера недоступны.
|
||||||
|
if (detectProvider(uri) == FeedVideoEmbedProvider.vk) {
|
||||||
|
params['js_api'] = '1';
|
||||||
|
}
|
||||||
|
|
||||||
|
return uri.replace(queryParameters: params);
|
||||||
|
}
|
||||||
|
|
||||||
|
static String buildHtml(String embedUrl) {
|
||||||
|
final uri = Uri.parse(embedUrl);
|
||||||
|
final isVk = detectProvider(uri) == FeedVideoEmbedProvider.vk;
|
||||||
|
final src = const HtmlEscape(HtmlEscapeMode.attribute).convert(embedUrl);
|
||||||
|
final headScripts = isVk ? '<script src="${FeedVideoEmbedScripts.vkVideoPlayerApiSrc}"></script>' : '';
|
||||||
|
final bootstrap = FeedVideoEmbedScripts.bootstrap(includeVk: isVk);
|
||||||
|
|
||||||
|
return '''
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||||
|
<style>
|
||||||
|
html, body { margin: 0; padding: 0; width: 100%; height: 100%; background: #000; overflow: hidden; }
|
||||||
|
iframe { position: absolute; inset: 0; width: 100%; height: 100%; border: 0; }
|
||||||
|
</style>
|
||||||
|
$headScripts
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<iframe
|
||||||
|
id="player"
|
||||||
|
src="$src"
|
||||||
|
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; fullscreen;"
|
||||||
|
allowfullscreen
|
||||||
|
referrerpolicy="strict-origin-when-cross-origin"
|
||||||
|
></iframe>
|
||||||
|
<script>
|
||||||
|
$bootstrap
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
''';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
/// JS-фрагменты для embed-плеера ленты.
|
||||||
|
///
|
||||||
|
/// Канал связи с Flutter: [readyChannel] через `flutter_inappwebview.callHandler`.
|
||||||
|
abstract final class FeedVideoEmbedScripts {
|
||||||
|
static const readyChannel = 'EmbedReady';
|
||||||
|
|
||||||
|
/// Уведомляет Flutter, что embed готов к показу.
|
||||||
|
static String get notifyReady =>
|
||||||
|
'''
|
||||||
|
function notifyReady() {
|
||||||
|
try {
|
||||||
|
window.flutter_inappwebview.callHandler('$readyChannel', 'ready');
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
''';
|
||||||
|
|
||||||
|
/// Уведомляет Flutter об ошибке воспроизведения.
|
||||||
|
static String get notifyError =>
|
||||||
|
'''
|
||||||
|
function notifyError() {
|
||||||
|
try {
|
||||||
|
window.flutter_inappwebview.callHandler('$readyChannel', 'error');
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
''';
|
||||||
|
|
||||||
|
/// Rutube: готовность через postMessage `player:canPlay`.
|
||||||
|
static String get rutubeCanPlayListener => '''
|
||||||
|
window.addEventListener('message', function (event) {
|
||||||
|
try {
|
||||||
|
var type = JSON.parse(event.data).type;
|
||||||
|
if (type === 'player:canPlay') {
|
||||||
|
notifyReady();
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
});
|
||||||
|
''';
|
||||||
|
|
||||||
|
/// VK Video JS API: воспроизведение началось на событии `started`.
|
||||||
|
/// Требует `js_api=1` в URL iframe и скрипт `videoplayer.js`.
|
||||||
|
static String get vkPlayerReadyHandler => '''
|
||||||
|
try {
|
||||||
|
if (typeof VK !== 'undefined' && VK.VideoPlayer) {
|
||||||
|
var vkPlayer = VK.VideoPlayer(document.getElementById('player'));
|
||||||
|
vkPlayer.on('started', function () {
|
||||||
|
notifyReady();
|
||||||
|
});
|
||||||
|
vkPlayer.on('error', function () {
|
||||||
|
notifyError();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
''';
|
||||||
|
|
||||||
|
/// Внешний скрипт VK VideoPlayer API.
|
||||||
|
static const vkVideoPlayerApiSrc = 'https://vk.ru/js/api/videoplayer.js';
|
||||||
|
|
||||||
|
/// Собирает inline `<script>` с общим bootstrap + опциональным VK-хендлером.
|
||||||
|
static String bootstrap({required bool includeVk}) {
|
||||||
|
final vkPart = includeVk ? vkPlayerReadyHandler : '';
|
||||||
|
return '''
|
||||||
|
(function () {
|
||||||
|
$notifyReady
|
||||||
|
$notifyError
|
||||||
|
$rutubeCanPlayListener
|
||||||
|
$vkPart
|
||||||
|
})();
|
||||||
|
''';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:developer';
|
||||||
|
|
||||||
|
import 'package:eatvio_app/features/feed/presentation/widgets/feed_video_player/feed_video_embed.dart';
|
||||||
|
import 'package:eatvio_app/features/feed/presentation/widgets/feed_video_player/feed_video_embed_scripts.dart';
|
||||||
|
import 'package:eatvio_app/features/feed/presentation/widgets/feed_video_player/feed_video_preview_placeholder.dart';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
|
||||||
|
|
||||||
|
/// Embed-видео в ленте.
|
||||||
|
///
|
||||||
|
/// 1) Пока пользователь не нажал play — только превью (без WebView).
|
||||||
|
/// 2) После play — превью + лоадер, WebView грузится скрыто.
|
||||||
|
/// 3) Когда embed готов — показываем WebView и запускаем воспроизведение.
|
||||||
|
///
|
||||||
|
/// Реализовано на `flutter_inappwebview` — в отличие от `webview_flutter` v4
|
||||||
|
/// нативно поддерживает fullscreen внутри embed-плеера (Rutube/VK/YouTube):
|
||||||
|
/// Android `WebChromeClient.onShowCustomView/onHideCustomView`.
|
||||||
|
class FeedVideoPlayer extends StatefulWidget {
|
||||||
|
const FeedVideoPlayer({super.key, required this.embedUrl, this.videoUrlThumb});
|
||||||
|
|
||||||
|
final String embedUrl;
|
||||||
|
|
||||||
|
/// Пока бэкенд не отдаёт превью — null, показывается серая заглушка.
|
||||||
|
final String? videoUrlThumb;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<FeedVideoPlayer> createState() => _FeedVideoPlayerState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _FeedVideoPlayerState extends State<FeedVideoPlayer> {
|
||||||
|
static const Duration _readyFallbackDelay = Duration(seconds: 4);
|
||||||
|
|
||||||
|
/// Пока null — WebView не рендерится. Заполняется по жесту play.
|
||||||
|
InAppWebViewInitialData? _initialData;
|
||||||
|
bool _isLoading = false;
|
||||||
|
bool _isReady = false;
|
||||||
|
bool _hasError = false;
|
||||||
|
|
||||||
|
/// Страховка готовности для провайдеров без сигнала ready (Rutube/VK).
|
||||||
|
Timer? _readyFallback;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didUpdateWidget(covariant FeedVideoPlayer oldWidget) {
|
||||||
|
super.didUpdateWidget(oldWidget);
|
||||||
|
if (oldWidget.embedUrl != widget.embedUrl || oldWidget.videoUrlThumb != widget.videoUrlThumb) {
|
||||||
|
_reset();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_readyFallback?.cancel();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _reset() {
|
||||||
|
_readyFallback?.cancel();
|
||||||
|
_readyFallback = null;
|
||||||
|
_initialData = null;
|
||||||
|
_isLoading = false;
|
||||||
|
_isReady = false;
|
||||||
|
_hasError = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _markReady() {
|
||||||
|
if (!mounted || _isReady || _hasError) return;
|
||||||
|
_readyFallback?.cancel();
|
||||||
|
_readyFallback = null;
|
||||||
|
setState(() {
|
||||||
|
_isReady = true;
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _markError() {
|
||||||
|
if (!mounted || _isReady) return;
|
||||||
|
_readyFallback?.cancel();
|
||||||
|
_readyFallback = null;
|
||||||
|
setState(() {
|
||||||
|
_hasError = true;
|
||||||
|
_isLoading = false;
|
||||||
|
_initialData = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _onPlayPressed() async {
|
||||||
|
if (_isLoading || _isReady || !mounted) return;
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_isLoading = true;
|
||||||
|
_hasError = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
final embedUri = FeedVideoEmbed.uriForPlayback(widget.embedUrl);
|
||||||
|
final baseUrl = '${embedUri.scheme}://${embedUri.host}/';
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_initialData = InAppWebViewInitialData(
|
||||||
|
data: FeedVideoEmbed.buildHtml(embedUri.toString()),
|
||||||
|
mimeType: 'text/html',
|
||||||
|
encoding: 'utf8',
|
||||||
|
baseUrl: WebUri(baseUrl),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
} catch (e, st) {
|
||||||
|
if (kDebugMode) {
|
||||||
|
debugPrint('FeedVideoPlayer load failed: $e\n$st');
|
||||||
|
}
|
||||||
|
_markError();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
InAppWebViewSettings get _settings => InAppWebViewSettings(
|
||||||
|
// Жест play уже был на кнопке — разрешаем старта без доп. тапа.
|
||||||
|
mediaPlaybackRequiresUserGesture: false,
|
||||||
|
allowsInlineMediaPlayback: true,
|
||||||
|
iframeAllow: 'accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; fullscreen',
|
||||||
|
iframeAllowFullscreen: true,
|
||||||
|
transparentBackground: false,
|
||||||
|
);
|
||||||
|
|
||||||
|
void _onWebViewCreated(InAppWebViewController controller) {
|
||||||
|
controller.addJavaScriptHandler(
|
||||||
|
handlerName: FeedVideoEmbedScripts.readyChannel,
|
||||||
|
callback: (args) {
|
||||||
|
final status = args.isNotEmpty ? args.first?.toString() : 'ready';
|
||||||
|
if (status == 'error') {
|
||||||
|
_markError();
|
||||||
|
} else {
|
||||||
|
_markReady();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onLoadStop(InAppWebViewController _, WebUri? _) {
|
||||||
|
// Если провайдер не шлёт сигнал готовности (Rutube `player:canPlay` / VK `started`),
|
||||||
|
// покажем WebView по таймауту после полной загрузки (напр. YouTube / OK).
|
||||||
|
if (_isReady || _hasError || !mounted) return;
|
||||||
|
_readyFallback?.cancel();
|
||||||
|
_readyFallback = Timer(_readyFallbackDelay, () {
|
||||||
|
if (mounted && !_isReady && !_hasError) _markReady();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onReceivedError(InAppWebViewController _, WebResourceRequest request, WebResourceError error) {
|
||||||
|
if (!mounted) return;
|
||||||
|
if (request.isForMainFrame != true) return;
|
||||||
|
// ERR_BLOCKED_BY_ORB — cross-origin блокировки subframe, не реальная ошибка плеера.
|
||||||
|
if (error.description.contains('ERR_BLOCKED_BY_ORB')) return;
|
||||||
|
if (_isReady) return;
|
||||||
|
if (kDebugMode) debugPrint('FeedVideoPlayer error: ${error.description}');
|
||||||
|
_markError();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onEnterFullscreen(InAppWebViewController _) {
|
||||||
|
SystemChrome.setPreferredOrientations([DeviceOrientation.landscapeLeft, DeviceOrientation.landscapeRight]);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onExitFullscreen(InAppWebViewController _) {
|
||||||
|
// Возвращаем системный набор ориентаций (не блокируем глобально в portrait).
|
||||||
|
SystemChrome.setPreferredOrientations([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final player = ColoredBox(
|
||||||
|
color: Colors.black,
|
||||||
|
child: Stack(
|
||||||
|
fit: StackFit.expand,
|
||||||
|
children: [
|
||||||
|
// WebView создаётся только после play и до ready остаётся скрытым под превью.
|
||||||
|
if (_initialData != null)
|
||||||
|
Opacity(
|
||||||
|
opacity: _isReady ? 1 : 0,
|
||||||
|
child: IgnorePointer(
|
||||||
|
ignoring: !_isReady,
|
||||||
|
child: InAppWebView(
|
||||||
|
key: ValueKey('feed-inappwebview-${widget.embedUrl}'),
|
||||||
|
initialData: _initialData,
|
||||||
|
initialSettings: _settings,
|
||||||
|
onWebViewCreated: _onWebViewCreated,
|
||||||
|
onConsoleMessage: (_, message) => log('FeedVideoPlayer console message: ${message.message}'),
|
||||||
|
onLoadStop: _onLoadStop,
|
||||||
|
onReceivedError: _onReceivedError,
|
||||||
|
onEnterFullscreen: _onEnterFullscreen,
|
||||||
|
onExitFullscreen: _onExitFullscreen,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (!_isReady)
|
||||||
|
FeedVideoPreviewPlaceholder(
|
||||||
|
previewUrl: widget.videoUrlThumb,
|
||||||
|
isLoading: _isLoading,
|
||||||
|
hasError: _hasError,
|
||||||
|
onPlay: _onPlayPressed,
|
||||||
|
onRetry: _onPlayPressed,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Пока превью/лоадер — не даём InkWell карточки перехватывать тап.
|
||||||
|
if (_isReady) return player;
|
||||||
|
return GestureDetector(onTap: () {}, behavior: HitTestBehavior.opaque, child: player);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
class FeedVideoPreviewPlaceholder extends StatelessWidget {
|
||||||
|
const FeedVideoPreviewPlaceholder({
|
||||||
|
super.key,
|
||||||
|
required this.previewUrl,
|
||||||
|
required this.isLoading,
|
||||||
|
required this.hasError,
|
||||||
|
required this.onPlay,
|
||||||
|
required this.onRetry,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String? previewUrl;
|
||||||
|
final bool isLoading;
|
||||||
|
final bool hasError;
|
||||||
|
final VoidCallback onPlay;
|
||||||
|
final VoidCallback onRetry;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Stack(
|
||||||
|
fit: StackFit.expand,
|
||||||
|
children: [
|
||||||
|
const ColoredBox(color: Colors.black),
|
||||||
|
if (previewUrl != null && previewUrl!.isNotEmpty)
|
||||||
|
CachedNetworkImage(
|
||||||
|
imageUrl: previewUrl!,
|
||||||
|
fit: BoxFit.contain,
|
||||||
|
width: double.infinity,
|
||||||
|
height: double.infinity,
|
||||||
|
errorWidget: (_, _, _) => const ColoredBox(color: Color(0xFF2C2C2C)),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
const ColoredBox(color: Color(0xFF2C2C2C)),
|
||||||
|
if (hasError)
|
||||||
|
Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.error_outline, size: 40, color: Colors.white54),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
TextButton(
|
||||||
|
onPressed: onRetry,
|
||||||
|
child: const Text('Повторить', style: TextStyle(color: Colors.white70)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else if (isLoading)
|
||||||
|
const Center(child: CircularProgressIndicator(color: Colors.white))
|
||||||
|
else
|
||||||
|
Center(
|
||||||
|
child: GestureDetector(
|
||||||
|
onTap: onPlay,
|
||||||
|
child: Container(
|
||||||
|
decoration: BoxDecoration(color: Colors.black.withValues(alpha: 0.45), shape: BoxShape.circle),
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
child: const Icon(Icons.play_arrow_rounded, color: Colors.white, size: 40),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export 'package:eatvio_app/features/feed/presentation/widgets/feed_video_player/feed_video_player_widget.dart';
|
||||||
@@ -116,9 +116,7 @@ class _PlanWidgetState extends State<PlanWidget> {
|
|||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
child: MarkdownHtmlWidget(
|
child: MarkdownHtmlWidget(
|
||||||
markdownText: widget.plan.details.length > 200
|
markdownText: widget.plan.details.length > 200 ? '${widget.plan.details.substring(0, 200)}...' : widget.plan.details,
|
||||||
? '${widget.plan.details.substring(0, 200)}...'
|
|
||||||
: widget.plan.details,
|
|
||||||
textColor: appTheme.black,
|
textColor: appTheme.black,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import 'package:eatvio_app/features/feed/models/comment_model.dart';
|
import 'package:eatvio_app/features/feed/models/comment_model.dart';
|
||||||
import 'package:eatvio_app/features/feed/models/diets_pagination_model.dart';
|
import 'package:eatvio_app/features/feed/models/diets_pagination_model.dart';
|
||||||
import 'package:eatvio_app/features/feed/models/feed_blog_post_model.dart';
|
import 'package:eatvio_app/features/feed/models/feed_blog_post_model.dart';
|
||||||
import 'package:eatvio_app/features/feed/models/feed_blog_post_settings_model.dart';
|
|
||||||
import 'package:eatvio_app/features/feed/models/feed_consultant_model.dart';
|
import 'package:eatvio_app/features/feed/models/feed_consultant_model.dart';
|
||||||
import 'package:eatvio_app/features/feed/models/plan_pagination_model.dart';
|
import 'package:eatvio_app/features/feed/models/plan_pagination_model.dart';
|
||||||
|
import 'package:eatvio_app/features/feed/models/post_feed_mapper.dart';
|
||||||
import 'package:eatvio_app/features/feed/models/recipes_model.dart';
|
import 'package:eatvio_app/features/feed/models/recipes_model.dart';
|
||||||
import 'package:eatvio_app/features/feed/models/view_model.dart';
|
import 'package:eatvio_app/features/feed/models/view_model.dart';
|
||||||
import 'package:eatvio_app/features/feed/services/feed_service.dart';
|
import 'package:eatvio_app/features/feed/services/feed_service.dart';
|
||||||
@@ -13,9 +13,12 @@ class FeedRepository {
|
|||||||
|
|
||||||
FeedRepository({required this.feedService});
|
FeedRepository({required this.feedService});
|
||||||
|
|
||||||
Future<FeedBlogPostSettingsModel> getFeedBlogPosts({String? query, int page = 1}) async {
|
Future<({List<FeedBlogPostModel> posts, String? nextCursor})> getFeed({String? search, String? cursor, int? currentUserId}) async {
|
||||||
return (await feedService.getBlogPostList(page: page, query: query));
|
final response = await feedService.getFeed(search: search, cursor: cursor);
|
||||||
|
return (
|
||||||
|
posts: response.data.map((item) => PostFeedMapper.fromPostFeedItem(item, currentUserId: currentUserId)).toList(),
|
||||||
|
nextCursor: response.meta.nextCursor,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<FeedBlogPostModel> getBlogPost({required int id}) async {
|
Future<FeedBlogPostModel> getBlogPost({required int id}) async {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import 'package:eatvio_app/features/feed/models/feed_blog_post_model.dart';
|
|||||||
import 'package:eatvio_app/features/feed/models/feed_blog_post_settings_model.dart';
|
import 'package:eatvio_app/features/feed/models/feed_blog_post_settings_model.dart';
|
||||||
import 'package:eatvio_app/features/feed/models/feed_consultant_model.dart';
|
import 'package:eatvio_app/features/feed/models/feed_consultant_model.dart';
|
||||||
import 'package:eatvio_app/features/feed/models/plan_pagination_model.dart';
|
import 'package:eatvio_app/features/feed/models/plan_pagination_model.dart';
|
||||||
|
import 'package:eatvio_app/features/feed/models/post_feed_pagination_model.dart';
|
||||||
import 'package:eatvio_app/features/feed/models/recipes_pagination_model.dart';
|
import 'package:eatvio_app/features/feed/models/recipes_pagination_model.dart';
|
||||||
import 'package:eatvio_app/features/feed/models/view_model.dart';
|
import 'package:eatvio_app/features/feed/models/view_model.dart';
|
||||||
import 'package:retrofit/retrofit.dart';
|
import 'package:retrofit/retrofit.dart';
|
||||||
@@ -18,7 +19,13 @@ abstract class FeedService {
|
|||||||
@GET('/api/blogPosts/index')
|
@GET('/api/blogPosts/index')
|
||||||
Future<FeedBlogPostSettingsModel> getBlogPostList({@Query('page') required int page, @Query('query_search') String? query});
|
Future<FeedBlogPostSettingsModel> getBlogPostList({@Query('page') required int page, @Query('query_search') String? query});
|
||||||
|
|
||||||
@GET('/api/blogPosts/show/{postId}')
|
@GET('/api/feed')
|
||||||
|
Future<PostFeedPaginationModel> getFeed({
|
||||||
|
@Query('search') String? search,
|
||||||
|
@Query('cursor') String? cursor,
|
||||||
|
});
|
||||||
|
|
||||||
|
@GET('/api/v2/blogPosts/show/{postId}')
|
||||||
Future<FeedBlogPostModel> getBlogPost({@Path('postId') required int postId});
|
Future<FeedBlogPostModel> getBlogPost({@Path('postId') required int postId});
|
||||||
|
|
||||||
@GET('/api/recipes/index')
|
@GET('/api/recipes/index')
|
||||||
|
|||||||
15
lib/features/payment/models/promo_applied_list_response.dart
Normal file
15
lib/features/payment/models/promo_applied_list_response.dart
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import 'package:eatvio_app/features/payment/models/promo_code_model.dart';
|
||||||
|
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||||
|
|
||||||
|
part 'promo_applied_list_response.freezed.dart';
|
||||||
|
part 'promo_applied_list_response.g.dart';
|
||||||
|
|
||||||
|
@freezed
|
||||||
|
abstract class PromoAppliedListResponse with _$PromoAppliedListResponse {
|
||||||
|
const factory PromoAppliedListResponse({
|
||||||
|
@JsonKey(name: 'promo_codes') required List<PromoCodeModel> promoCodes,
|
||||||
|
}) = _PromoAppliedListResponse;
|
||||||
|
|
||||||
|
factory PromoAppliedListResponse.fromJson(Map<String, dynamic> json) =>
|
||||||
|
_$PromoAppliedListResponseFromJson(json);
|
||||||
|
}
|
||||||
21
lib/features/payment/models/promo_apply_response.dart
Normal file
21
lib/features/payment/models/promo_apply_response.dart
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import 'package:eatvio_app/features/payment/models/promo_code_model.dart';
|
||||||
|
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||||
|
|
||||||
|
part 'promo_apply_response.freezed.dart';
|
||||||
|
part 'promo_apply_response.g.dart';
|
||||||
|
|
||||||
|
@freezed
|
||||||
|
abstract class PromoApplyResponse with _$PromoApplyResponse {
|
||||||
|
const factory PromoApplyResponse({
|
||||||
|
required bool success,
|
||||||
|
String? error,
|
||||||
|
@JsonKey(name: 'promo_code') PromoCodeModel? promoCode,
|
||||||
|
@JsonKey(name: 'discount_percent') int? discountPercent,
|
||||||
|
@JsonKey(name: 'discount_amount_rub') double? discountAmountRub,
|
||||||
|
@JsonKey(name: 'original_amount_rub') double? originalAmountRub,
|
||||||
|
@JsonKey(name: 'final_amount_rub') double? finalAmountRub,
|
||||||
|
}) = _PromoApplyResponse;
|
||||||
|
|
||||||
|
factory PromoApplyResponse.fromJson(Map<String, dynamic> json) =>
|
||||||
|
_$PromoApplyResponseFromJson(json);
|
||||||
|
}
|
||||||
22
lib/features/payment/models/promo_code_model.dart
Normal file
22
lib/features/payment/models/promo_code_model.dart
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||||
|
|
||||||
|
part 'promo_code_model.freezed.dart';
|
||||||
|
part 'promo_code_model.g.dart';
|
||||||
|
|
||||||
|
@freezed
|
||||||
|
abstract class PromoCodeModel with _$PromoCodeModel {
|
||||||
|
const factory PromoCodeModel({
|
||||||
|
required int id,
|
||||||
|
@JsonKey(name: 'chosen_plan_id') required int chosenPlanId,
|
||||||
|
@JsonKey(name: 'promo_code') required String promoCode,
|
||||||
|
@JsonKey(name: 'discount_percent') required int discountPercent,
|
||||||
|
@JsonKey(name: 'discount_amount_rub') required double discountAmountRub,
|
||||||
|
required String state,
|
||||||
|
@JsonKey(name: 'applied_at') String? appliedAt,
|
||||||
|
@JsonKey(name: 'confirmed_at') String? confirmedAt,
|
||||||
|
@JsonKey(name: 'cancelled_at') String? cancelledAt,
|
||||||
|
}) = _PromoCodeModel;
|
||||||
|
|
||||||
|
factory PromoCodeModel.fromJson(Map<String, dynamic> json) =>
|
||||||
|
_$PromoCodeModelFromJson(json);
|
||||||
|
}
|
||||||
15
lib/features/payment/models/promo_remove_response.dart
Normal file
15
lib/features/payment/models/promo_remove_response.dart
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||||
|
|
||||||
|
part 'promo_remove_response.freezed.dart';
|
||||||
|
part 'promo_remove_response.g.dart';
|
||||||
|
|
||||||
|
@freezed
|
||||||
|
abstract class PromoRemoveResponse with _$PromoRemoveResponse {
|
||||||
|
const factory PromoRemoveResponse({
|
||||||
|
required bool success,
|
||||||
|
String? error,
|
||||||
|
}) = _PromoRemoveResponse;
|
||||||
|
|
||||||
|
factory PromoRemoveResponse.fromJson(Map<String, dynamic> json) =>
|
||||||
|
_$PromoRemoveResponseFromJson(json);
|
||||||
|
}
|
||||||
20
lib/features/payment/models/promo_validate_response.dart
Normal file
20
lib/features/payment/models/promo_validate_response.dart
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||||
|
|
||||||
|
part 'promo_validate_response.freezed.dart';
|
||||||
|
part 'promo_validate_response.g.dart';
|
||||||
|
|
||||||
|
@freezed
|
||||||
|
abstract class PromoValidateResponse with _$PromoValidateResponse {
|
||||||
|
const factory PromoValidateResponse({
|
||||||
|
required bool valid,
|
||||||
|
String? error,
|
||||||
|
@JsonKey(name: 'promo_code') String? promoCode,
|
||||||
|
@JsonKey(name: 'discount_percent') int? discountPercent,
|
||||||
|
@JsonKey(name: 'discount_amount_rub') double? discountAmountRub,
|
||||||
|
@JsonKey(name: 'original_amount_rub') double? originalAmountRub,
|
||||||
|
@JsonKey(name: 'final_amount_rub') double? finalAmountRub,
|
||||||
|
}) = _PromoValidateResponse;
|
||||||
|
|
||||||
|
factory PromoValidateResponse.fromJson(Map<String, dynamic> json) =>
|
||||||
|
_$PromoValidateResponseFromJson(json);
|
||||||
|
}
|
||||||
@@ -1,22 +1,73 @@
|
|||||||
|
import 'package:eatvio_app/core/locator_service.dart';
|
||||||
|
import 'package:eatvio_app/core/utils/toasts.dart';
|
||||||
|
import 'package:eatvio_app/features/payment/models/promo_apply_response.dart';
|
||||||
|
import 'package:eatvio_app/features/payment/models/promo_code_model.dart';
|
||||||
|
import 'package:eatvio_app/features/payment/models/promo_validate_response.dart';
|
||||||
import 'package:eatvio_app/features/payment/presentation/widgets/promo_code_gate_bottom_sheet.dart';
|
import 'package:eatvio_app/features/payment/presentation/widgets/promo_code_gate_bottom_sheet.dart';
|
||||||
import 'package:eatvio_app/features/payment/presentation/widgets/promo_code_modal.dart';
|
import 'package:eatvio_app/features/payment/presentation/widgets/promo_code_modal.dart';
|
||||||
import 'package:eatvio_app/features/payment/presentation/widgets/sbp_banks_bottom_sheet.dart';
|
import 'package:eatvio_app/features/payment/presentation/widgets/sbp_banks_bottom_sheet.dart';
|
||||||
import 'package:eatvio_app/generated/l10n.dart';
|
import 'package:eatvio_app/features/payment/repositories/payment_repository.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
/// Оркестрирует флоу оплаты консультации:
|
/// Оркестрирует флоу оплаты консультации:
|
||||||
/// гейт «У вас есть промокод?» → (Да → форма промокода) → SbpBanks.
|
/// applied rehydrate → (или гейт → форма) → apply при необходимости → SbpBanks.
|
||||||
Future<void> showConsultationPaymentFlow(
|
Future<void> showConsultationPaymentFlow(
|
||||||
BuildContext context, {
|
BuildContext context, {
|
||||||
required int planId,
|
required int planId,
|
||||||
Future<bool?> Function(BuildContext)? showGate,
|
Future<bool?> Function(BuildContext)? showGate,
|
||||||
Future<PromoCodeResult?> Function(BuildContext, {Future<PromoValidation> Function(String)? onValidate})? showForm,
|
Future<PromoCodeResult?> Function(
|
||||||
|
BuildContext, {
|
||||||
|
Future<PromoValidation> Function(String)? onValidate,
|
||||||
|
Future<bool> Function()? onRemove,
|
||||||
|
PromoCodeModel? initialPromo,
|
||||||
|
})? showForm,
|
||||||
Future<void> Function(BuildContext, {required int planId})? showSbp,
|
Future<void> Function(BuildContext, {required int planId})? showSbp,
|
||||||
|
Future<PromoValidateResponse> Function({
|
||||||
|
required int planId,
|
||||||
|
required String promoCode,
|
||||||
|
})? validatePromo,
|
||||||
|
Future<PromoApplyResponse> Function({
|
||||||
|
required int planId,
|
||||||
|
required String promoCode,
|
||||||
|
})? applyPromo,
|
||||||
|
Future<PromoCodeModel?> Function(int planId)? listApplied,
|
||||||
|
Future<bool> Function(int planId)? removePromo,
|
||||||
}) async {
|
}) async {
|
||||||
final gate = showGate ?? _showGate;
|
final gate = showGate ?? _showGate;
|
||||||
final form = showForm ?? _showForm;
|
final form = showForm ?? _showForm;
|
||||||
final sbp = showSbp ?? _showSbp;
|
final sbp = showSbp ?? _showSbp;
|
||||||
final s = S.of(context);
|
final validate = validatePromo ?? _defaultValidatePromo;
|
||||||
|
final apply = applyPromo ?? _defaultApplyPromo;
|
||||||
|
final findApplied = listApplied ?? _defaultFindApplied;
|
||||||
|
final remove = removePromo ?? _defaultRemovePromo;
|
||||||
|
|
||||||
|
final existing = await findApplied(planId);
|
||||||
|
if (!context.mounted) return;
|
||||||
|
|
||||||
|
if (existing != null) {
|
||||||
|
final formResult = await form(
|
||||||
|
context,
|
||||||
|
initialPromo: existing,
|
||||||
|
onRemove: () => remove(planId),
|
||||||
|
onValidate: (code) => _mapValidateResponse(
|
||||||
|
validate(planId: planId, promoCode: code),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (formResult == null || !context.mounted) return;
|
||||||
|
|
||||||
|
if (formResult.hasPromoCode && !formResult.alreadyApplied) {
|
||||||
|
final applied = await _tryApplyPromo(
|
||||||
|
apply: apply,
|
||||||
|
planId: planId,
|
||||||
|
promoCode: formResult.code!,
|
||||||
|
);
|
||||||
|
if (!applied || !context.mounted) return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!context.mounted) return;
|
||||||
|
await sbp(context, planId: planId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
final gateChoice = await gate(context);
|
final gateChoice = await gate(context);
|
||||||
if (gateChoice == null || !context.mounted) return;
|
if (gateChoice == null || !context.mounted) return;
|
||||||
@@ -24,9 +75,20 @@ Future<void> showConsultationPaymentFlow(
|
|||||||
if (gateChoice) {
|
if (gateChoice) {
|
||||||
final formResult = await form(
|
final formResult = await form(
|
||||||
context,
|
context,
|
||||||
onValidate: (_) async => PromoValidation.failure(s.promo_invalid),
|
onValidate: (code) => _mapValidateResponse(
|
||||||
|
validate(planId: planId, promoCode: code),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
if (formResult == null || !context.mounted) return;
|
if (formResult == null || !context.mounted) return;
|
||||||
|
|
||||||
|
if (formResult.hasPromoCode && !formResult.alreadyApplied) {
|
||||||
|
final applied = await _tryApplyPromo(
|
||||||
|
apply: apply,
|
||||||
|
planId: planId,
|
||||||
|
promoCode: formResult.code!,
|
||||||
|
);
|
||||||
|
if (!applied || !context.mounted) return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!context.mounted) return;
|
if (!context.mounted) return;
|
||||||
@@ -40,10 +102,96 @@ Future<bool?> _showGate(BuildContext context) {
|
|||||||
Future<PromoCodeResult?> _showForm(
|
Future<PromoCodeResult?> _showForm(
|
||||||
BuildContext context, {
|
BuildContext context, {
|
||||||
Future<PromoValidation> Function(String)? onValidate,
|
Future<PromoValidation> Function(String)? onValidate,
|
||||||
|
Future<bool> Function()? onRemove,
|
||||||
|
PromoCodeModel? initialPromo,
|
||||||
}) {
|
}) {
|
||||||
return ItvioPromoCodeModal.show(context, onValidate: onValidate);
|
return ItvioPromoCodeModal.show(
|
||||||
|
context,
|
||||||
|
onValidate: onValidate,
|
||||||
|
onRemove: onRemove,
|
||||||
|
initialPromo: initialPromo,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _showSbp(BuildContext context, {required int planId}) {
|
Future<void> _showSbp(BuildContext context, {required int planId}) {
|
||||||
return showSbpBanksBottomSheet(context, planId: planId);
|
return showSbpBanksBottomSheet(context, planId: planId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<PromoValidateResponse> _defaultValidatePromo({
|
||||||
|
required int planId,
|
||||||
|
required String promoCode,
|
||||||
|
}) {
|
||||||
|
return sl<PaymentRepository>().validatePromoCode(
|
||||||
|
planId: planId,
|
||||||
|
promoCode: promoCode,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<PromoApplyResponse> _defaultApplyPromo({
|
||||||
|
required int planId,
|
||||||
|
required String promoCode,
|
||||||
|
}) {
|
||||||
|
return sl<PaymentRepository>().applyPromoCode(
|
||||||
|
planId: planId,
|
||||||
|
promoCode: promoCode,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<PromoCodeModel?> _defaultFindApplied(int planId) {
|
||||||
|
return sl<PaymentRepository>().findAppliedPromoForPlan(planId);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> _defaultRemovePromo(int planId) async {
|
||||||
|
try {
|
||||||
|
final response = await sl<PaymentRepository>().removePromoCode(planId: planId);
|
||||||
|
if (response.success) return true;
|
||||||
|
showErrorToast(response.error ?? 'Не удалось убрать промокод.');
|
||||||
|
return false;
|
||||||
|
} catch (_) {
|
||||||
|
showErrorToast('Не удалось убрать промокод. Попробуйте позже');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<PromoValidation> _mapValidateResponse(
|
||||||
|
Future<PromoValidateResponse> request,
|
||||||
|
) async {
|
||||||
|
try {
|
||||||
|
final response = await request;
|
||||||
|
if (!response.valid) {
|
||||||
|
return PromoValidation.failure(
|
||||||
|
response.error ?? 'Промокод невалиден.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return PromoValidation.success(
|
||||||
|
discountPercent: response.discountPercent,
|
||||||
|
discountAmountRub: response.discountAmountRub,
|
||||||
|
originalAmountRub: response.originalAmountRub,
|
||||||
|
finalAmountRub: response.finalAmountRub,
|
||||||
|
);
|
||||||
|
} catch (_) {
|
||||||
|
return const PromoValidation.failure(
|
||||||
|
'Не удалось проверить промокод. Попробуйте позже',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> _tryApplyPromo({
|
||||||
|
required Future<PromoApplyResponse> Function({
|
||||||
|
required int planId,
|
||||||
|
required String promoCode,
|
||||||
|
}) apply,
|
||||||
|
required int planId,
|
||||||
|
required String promoCode,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
final response = await apply(planId: planId, promoCode: promoCode);
|
||||||
|
if (response.success) return true;
|
||||||
|
|
||||||
|
showErrorToast(response.error ?? 'Не удалось применить промокод.');
|
||||||
|
return false;
|
||||||
|
} catch (_) {
|
||||||
|
showErrorToast('Не удалось применить промокод. Попробуйте позже');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,45 +1,87 @@
|
|||||||
import 'package:eatvio_app/core/theme/custom_text_style.dart';
|
import 'package:eatvio_app/core/theme/custom_text_style.dart';
|
||||||
import 'package:eatvio_app/core/theme/theme_helper.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_handler_bar.dart';
|
||||||
|
import 'package:eatvio_app/features/payment/models/promo_code_model.dart';
|
||||||
import 'package:eatvio_app/generated/l10n.dart';
|
import 'package:eatvio_app/generated/l10n.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
/// Результат работы модалки промокода.
|
/// Результат работы модалки промокода.
|
||||||
class PromoCodeResult {
|
class PromoCodeResult {
|
||||||
const PromoCodeResult({this.code});
|
const PromoCodeResult({
|
||||||
|
this.code,
|
||||||
|
this.discountPercent,
|
||||||
|
this.discountAmountRub,
|
||||||
|
this.originalAmountRub,
|
||||||
|
this.finalAmountRub,
|
||||||
|
this.alreadyApplied = false,
|
||||||
|
});
|
||||||
|
|
||||||
/// Применённый промокод или null, если пользователь продолжил без него.
|
/// Применённый промокод или null, если пользователь продолжил без него.
|
||||||
final String? code;
|
final String? code;
|
||||||
|
final int? discountPercent;
|
||||||
|
final double? discountAmountRub;
|
||||||
|
final double? originalAmountRub;
|
||||||
|
final double? finalAmountRub;
|
||||||
|
|
||||||
|
/// true — промокод уже на сервере (rehydrate Continue), apply не нужен.
|
||||||
|
final bool alreadyApplied;
|
||||||
|
|
||||||
bool get hasPromoCode => code != null;
|
bool get hasPromoCode => code != null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Ответ проверки промокода.
|
/// Ответ проверки промокода.
|
||||||
class PromoValidation {
|
class PromoValidation {
|
||||||
const PromoValidation.success() : error = null;
|
const PromoValidation.success({
|
||||||
const PromoValidation.failure(this.error);
|
this.discountPercent,
|
||||||
|
this.discountAmountRub,
|
||||||
|
this.originalAmountRub,
|
||||||
|
this.finalAmountRub,
|
||||||
|
}) : error = null;
|
||||||
|
|
||||||
|
const PromoValidation.failure(this.error)
|
||||||
|
: discountPercent = null,
|
||||||
|
discountAmountRub = null,
|
||||||
|
originalAmountRub = null,
|
||||||
|
finalAmountRub = null;
|
||||||
|
|
||||||
final String? error;
|
final String? error;
|
||||||
|
final int? discountPercent;
|
||||||
|
final double? discountAmountRub;
|
||||||
|
final double? originalAmountRub;
|
||||||
|
final double? finalAmountRub;
|
||||||
|
|
||||||
bool get isValid => error == null;
|
bool get isValid => error == null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Модальное окно ввода промокода.
|
/// Модальное окно ввода / просмотра применённого промокода.
|
||||||
class ItvioPromoCodeModal extends StatefulWidget {
|
class ItvioPromoCodeModal extends StatefulWidget {
|
||||||
const ItvioPromoCodeModal({super.key, this.onValidate});
|
const ItvioPromoCodeModal({
|
||||||
|
super.key,
|
||||||
|
this.onValidate,
|
||||||
|
this.onRemove,
|
||||||
|
this.initialPromo,
|
||||||
|
});
|
||||||
|
|
||||||
final Future<PromoValidation> Function(String code)? onValidate;
|
final Future<PromoValidation> Function(String code)? onValidate;
|
||||||
|
final Future<bool> Function()? onRemove;
|
||||||
|
final PromoCodeModel? initialPromo;
|
||||||
|
|
||||||
static Future<PromoCodeResult?> show(
|
static Future<PromoCodeResult?> show(
|
||||||
BuildContext context, {
|
BuildContext context, {
|
||||||
Future<PromoValidation> Function(String code)? onValidate,
|
Future<PromoValidation> Function(String code)? onValidate,
|
||||||
|
Future<bool> Function()? onRemove,
|
||||||
|
PromoCodeModel? initialPromo,
|
||||||
}) {
|
}) {
|
||||||
return showModalBottomSheet<PromoCodeResult>(
|
return showModalBottomSheet<PromoCodeResult>(
|
||||||
context: context,
|
context: context,
|
||||||
isScrollControlled: true,
|
isScrollControlled: true,
|
||||||
useSafeArea: true,
|
useSafeArea: true,
|
||||||
backgroundColor: Colors.transparent,
|
backgroundColor: Colors.transparent,
|
||||||
builder: (_) => ItvioPromoCodeModal(onValidate: onValidate),
|
builder: (_) => ItvioPromoCodeModal(
|
||||||
|
onValidate: onValidate,
|
||||||
|
onRemove: onRemove,
|
||||||
|
initialPromo: initialPromo,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,14 +89,32 @@ class ItvioPromoCodeModal extends StatefulWidget {
|
|||||||
State<ItvioPromoCodeModal> createState() => _ItvioPromoCodeModalState();
|
State<ItvioPromoCodeModal> createState() => _ItvioPromoCodeModalState();
|
||||||
}
|
}
|
||||||
|
|
||||||
enum _ModalState { input, loading }
|
enum _ModalState { input, loading, applied }
|
||||||
|
|
||||||
class _ItvioPromoCodeModalState extends State<ItvioPromoCodeModal> {
|
class _ItvioPromoCodeModalState extends State<ItvioPromoCodeModal> {
|
||||||
final _controller = TextEditingController();
|
final _controller = TextEditingController();
|
||||||
final _focusNode = FocusNode();
|
final _focusNode = FocusNode();
|
||||||
|
|
||||||
_ModalState _state = _ModalState.input;
|
late _ModalState _state;
|
||||||
String? _errorText;
|
String? _errorText;
|
||||||
|
int? _discountPercent;
|
||||||
|
double? _discountAmountRub;
|
||||||
|
String? _appliedCode;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
final initial = widget.initialPromo;
|
||||||
|
if (initial != null) {
|
||||||
|
_state = _ModalState.applied;
|
||||||
|
_appliedCode = initial.promoCode;
|
||||||
|
_discountPercent = initial.discountPercent;
|
||||||
|
_discountAmountRub = initial.discountAmountRub;
|
||||||
|
_controller.text = initial.promoCode;
|
||||||
|
} else {
|
||||||
|
_state = _ModalState.input;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
@@ -98,7 +158,62 @@ class _ItvioPromoCodeModalState extends State<ItvioPromoCodeModal> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
Navigator.of(context).pop(PromoCodeResult(code: code));
|
Navigator.of(context).pop(
|
||||||
|
PromoCodeResult(
|
||||||
|
code: code,
|
||||||
|
discountPercent: validation.discountPercent,
|
||||||
|
discountAmountRub: validation.discountAmountRub,
|
||||||
|
originalAmountRub: validation.originalAmountRub,
|
||||||
|
finalAmountRub: validation.finalAmountRub,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onContinueApplied() {
|
||||||
|
Navigator.of(context).pop(
|
||||||
|
PromoCodeResult(
|
||||||
|
code: _appliedCode,
|
||||||
|
discountPercent: _discountPercent,
|
||||||
|
discountAmountRub: _discountAmountRub,
|
||||||
|
alreadyApplied: true,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _onRemove() async {
|
||||||
|
setState(() {
|
||||||
|
_state = _ModalState.loading;
|
||||||
|
_errorText = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
var success = true;
|
||||||
|
try {
|
||||||
|
if (widget.onRemove != null) {
|
||||||
|
success = await widget.onRemove!();
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
success = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
|
if (!success) {
|
||||||
|
setState(() {
|
||||||
|
_state = _ModalState.applied;
|
||||||
|
_errorText = S.of(context).promo_remove_failed;
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_state = _ModalState.input;
|
||||||
|
_appliedCode = null;
|
||||||
|
_discountPercent = null;
|
||||||
|
_discountAmountRub = null;
|
||||||
|
_errorText = null;
|
||||||
|
_controller.clear();
|
||||||
|
});
|
||||||
|
_focusNode.requestFocus();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onSkip() {
|
void _onSkip() {
|
||||||
@@ -107,6 +222,10 @@ class _ItvioPromoCodeModalState extends State<ItvioPromoCodeModal> {
|
|||||||
|
|
||||||
bool get _isLoading => _state == _ModalState.loading;
|
bool get _isLoading => _state == _ModalState.loading;
|
||||||
|
|
||||||
|
/// Applied UI while loading remove (state=loading, but promo still shown).
|
||||||
|
bool get _isApplied =>
|
||||||
|
_appliedCode != null && _state != _ModalState.input;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final bottomInset = MediaQuery.of(context).viewInsets.bottom;
|
final bottomInset = MediaQuery.of(context).viewInsets.bottom;
|
||||||
@@ -127,40 +246,51 @@ class _ItvioPromoCodeModalState extends State<ItvioPromoCodeModal> {
|
|||||||
children: [
|
children: [
|
||||||
const Center(child: BottomSheetHandlerBar()),
|
const Center(child: BottomSheetHandlerBar()),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
_buildHeader(),
|
_buildHeader(s),
|
||||||
|
if (_isApplied) ...[
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Text(
|
||||||
|
s.promo_applied_subtitle,
|
||||||
|
style: const TextStyle(fontSize: 14, color: Color(0xFF6B7684)),
|
||||||
|
),
|
||||||
|
],
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
const Text(
|
Text(
|
||||||
'Промокод',
|
s.promo_code,
|
||||||
style: TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
color: Color(0xFF6B7684),
|
color: Color(0xFF6B7684),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
_buildField(),
|
_buildField(s),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
_buildHint(),
|
_buildHint(s),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
_buildApplyButton(s),
|
if (_isApplied) ...[
|
||||||
const SizedBox(height: 6),
|
_buildContinueButton(s),
|
||||||
_buildSkipButton(s),
|
const SizedBox(height: 6),
|
||||||
|
_buildRemoveButton(s),
|
||||||
|
] else ...[
|
||||||
|
_buildApplyButton(s),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
_buildSkipButton(s),
|
||||||
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildHeader() {
|
Widget _buildHeader(S s) {
|
||||||
final s = S.of(context);
|
|
||||||
return Text(
|
return Text(
|
||||||
s.have_promo_code_form,
|
_isApplied ? s.promo_applied_title : s.have_promo_code_form,
|
||||||
style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black),
|
style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildField() {
|
Widget _buildField(S s) {
|
||||||
final hasError = _errorText != null;
|
final hasError = _errorText != null && !_isApplied;
|
||||||
final s = S.of(context);
|
|
||||||
|
|
||||||
return AnimatedContainer(
|
return AnimatedContainer(
|
||||||
duration: const Duration(milliseconds: 150),
|
duration: const Duration(milliseconds: 150),
|
||||||
@@ -171,7 +301,7 @@ class _ItvioPromoCodeModalState extends State<ItvioPromoCodeModal> {
|
|||||||
width: 2,
|
width: 2,
|
||||||
color: hasError
|
color: hasError
|
||||||
? appTheme.red
|
? appTheme.red
|
||||||
: (_focusNode.hasFocus
|
: (_focusNode.hasFocus && !_isApplied
|
||||||
? appTheme.blueA400
|
? appTheme.blueA400
|
||||||
: Colors.transparent),
|
: Colors.transparent),
|
||||||
),
|
),
|
||||||
@@ -182,14 +312,16 @@ class _ItvioPromoCodeModalState extends State<ItvioPromoCodeModal> {
|
|||||||
child: TextField(
|
child: TextField(
|
||||||
controller: _controller,
|
controller: _controller,
|
||||||
focusNode: _focusNode,
|
focusNode: _focusNode,
|
||||||
enabled: !_isLoading,
|
enabled: !_isLoading && !_isApplied,
|
||||||
textCapitalization: TextCapitalization.characters,
|
textCapitalization: TextCapitalization.characters,
|
||||||
onChanged: (_) {
|
onChanged: (_) {
|
||||||
if (_errorText != null) {
|
if (_errorText != null) {
|
||||||
setState(() => _errorText = null);
|
setState(() => _errorText = null);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onSubmitted: (_) => _apply(),
|
onSubmitted: (_) {
|
||||||
|
if (!_isApplied) _apply();
|
||||||
|
},
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
hintText: s.enter_promo_code,
|
hintText: s.enter_promo_code,
|
||||||
hintStyle: const TextStyle(
|
hintStyle: const TextStyle(
|
||||||
@@ -202,9 +334,11 @@ class _ItvioPromoCodeModalState extends State<ItvioPromoCodeModal> {
|
|||||||
vertical: 14,
|
vertical: 14,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
style: const TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 15,
|
fontSize: 15,
|
||||||
color: Color(0xFF24303F),
|
color: _isApplied
|
||||||
|
? const Color(0xFF6B7684)
|
||||||
|
: const Color(0xFF24303F),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -213,13 +347,17 @@ class _ItvioPromoCodeModalState extends State<ItvioPromoCodeModal> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildHint() {
|
Widget _buildHint(S s) {
|
||||||
final String text;
|
final String text;
|
||||||
final Color color;
|
final Color color;
|
||||||
|
|
||||||
if (_errorText != null) {
|
if (_errorText != null) {
|
||||||
text = _errorText!;
|
text = _errorText!;
|
||||||
color = appTheme.red;
|
color = appTheme.red;
|
||||||
|
} else if (_isApplied && _discountPercent != null) {
|
||||||
|
final amount = (_discountAmountRub ?? 0).toStringAsFixed(0);
|
||||||
|
text = s.promo_discount_hint(_discountPercent!, amount);
|
||||||
|
color = const Color(0xFF27A06B);
|
||||||
} else {
|
} else {
|
||||||
text = '';
|
text = '';
|
||||||
color = Colors.transparent;
|
color = Colors.transparent;
|
||||||
@@ -264,6 +402,50 @@ class _ItvioPromoCodeModalState extends State<ItvioPromoCodeModal> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _buildContinueButton(S s) {
|
||||||
|
return SizedBox(
|
||||||
|
height: 50,
|
||||||
|
child: FilledButton(
|
||||||
|
onPressed: _isLoading ? null : _onContinueApplied,
|
||||||
|
style: FilledButton.styleFrom(
|
||||||
|
backgroundColor: appTheme.blueA400,
|
||||||
|
foregroundColor: appTheme.whiteA700,
|
||||||
|
disabledBackgroundColor: appTheme.blueA400.withValues(alpha: 0.55),
|
||||||
|
shape: const StadiumBorder(),
|
||||||
|
textStyle: const TextStyle(
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Text(s.continue_),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildRemoveButton(S s) {
|
||||||
|
return SizedBox(
|
||||||
|
height: 46,
|
||||||
|
child: TextButton(
|
||||||
|
onPressed: _isLoading ? null : _onRemove,
|
||||||
|
style: TextButton.styleFrom(
|
||||||
|
foregroundColor: appTheme.blueA400,
|
||||||
|
shape: const StadiumBorder(),
|
||||||
|
textStyle: const TextStyle(fontSize: 14),
|
||||||
|
),
|
||||||
|
child: _isLoading
|
||||||
|
? SizedBox(
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2.5,
|
||||||
|
color: appTheme.blueA400,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: Text(s.promo_remove),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Widget _buildSkipButton(S s) {
|
Widget _buildSkipButton(S s) {
|
||||||
return SizedBox(
|
return SizedBox(
|
||||||
height: 46,
|
height: 46,
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
|
import 'package:dio/dio.dart';
|
||||||
import 'package:eatvio_app/features/chat/models/payment_model.dart';
|
import 'package:eatvio_app/features/chat/models/payment_model.dart';
|
||||||
import 'package:eatvio_app/features/chat/services/chat_service.dart';
|
import 'package:eatvio_app/features/chat/services/chat_service.dart';
|
||||||
|
import 'package:eatvio_app/features/payment/models/promo_applied_list_response.dart';
|
||||||
|
import 'package:eatvio_app/features/payment/models/promo_apply_response.dart';
|
||||||
|
import 'package:eatvio_app/features/payment/models/promo_code_model.dart';
|
||||||
|
import 'package:eatvio_app/features/payment/models/promo_remove_response.dart';
|
||||||
|
import 'package:eatvio_app/features/payment/models/promo_validate_response.dart';
|
||||||
|
|
||||||
/// Репозиторий оплаты консультаций (chosen_plan) через Alfa Bank / СБП.
|
/// Репозиторий оплаты консультаций (chosen_plan) через Alfa Bank / СБП.
|
||||||
class PaymentRepository {
|
class PaymentRepository {
|
||||||
@@ -15,4 +21,67 @@ class PaymentRepository {
|
|||||||
body: const {'payment_provider': 'alfa-bank'},
|
body: const {'payment_provider': 'alfa-bank'},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Список applied-промокодов текущего пользователя.
|
||||||
|
Future<PromoAppliedListResponse> listAppliedPromoCodes() {
|
||||||
|
return chatService.listAppliedPromoCodes();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Applied-промокод для конкретного chosen_plan, либо null (нет / ошибка сети).
|
||||||
|
Future<PromoCodeModel?> findAppliedPromoForPlan(int planId) async {
|
||||||
|
try {
|
||||||
|
final response = await listAppliedPromoCodes();
|
||||||
|
for (final promo in response.promoCodes) {
|
||||||
|
if (promo.chosenPlanId == planId && promo.state == 'applied') {
|
||||||
|
return promo;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Preview-валидация промокода без применения.
|
||||||
|
Future<PromoValidateResponse> validatePromoCode({
|
||||||
|
required int planId,
|
||||||
|
required String promoCode,
|
||||||
|
}) {
|
||||||
|
return chatService.validatePromoCode(
|
||||||
|
id: planId,
|
||||||
|
body: {'promo_code': promoCode},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Применяет промокод к chosen_plan. При HTTP 422 возвращает тело ответа.
|
||||||
|
Future<PromoApplyResponse> applyPromoCode({
|
||||||
|
required int planId,
|
||||||
|
required String promoCode,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
return await chatService.applyPromoCode(
|
||||||
|
id: planId,
|
||||||
|
body: {'promo_code': promoCode},
|
||||||
|
);
|
||||||
|
} on DioException catch (e) {
|
||||||
|
final data = e.response?.data;
|
||||||
|
if (data is Map<String, dynamic>) {
|
||||||
|
return PromoApplyResponse.fromJson(data);
|
||||||
|
}
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Удаляет применённый промокод (idempotent).
|
||||||
|
Future<PromoRemoveResponse> removePromoCode({required int planId}) async {
|
||||||
|
try {
|
||||||
|
return await chatService.removePromoCode(id: planId);
|
||||||
|
} on DioException catch (e) {
|
||||||
|
final data = e.response?.data;
|
||||||
|
if (data is Map<String, dynamic>) {
|
||||||
|
return PromoRemoveResponse.fromJson(data);
|
||||||
|
}
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,9 @@ class ProfileCubit extends Cubit<ProfileState> {
|
|||||||
final ConsultantRepository consultantRepository;
|
final ConsultantRepository consultantRepository;
|
||||||
String _currentPeriod = '1';
|
String _currentPeriod = '1';
|
||||||
|
|
||||||
|
/// Bottom-nav / own profile instance, used to refresh posts after create/update/delete.
|
||||||
|
static ProfileCubit? myProfileCubit;
|
||||||
|
|
||||||
ProfileCubit({required this.profileRepository, required this.chatRepository, required this.consultantRepository}) : super(const ProfileState.loading());
|
ProfileCubit({required this.profileRepository, required this.chatRepository, required this.consultantRepository}) : super(const ProfileState.loading());
|
||||||
|
|
||||||
Future<void> getProfileBySlug(String slug) async {
|
Future<void> getProfileBySlug(String slug) async {
|
||||||
@@ -49,6 +52,7 @@ class ProfileCubit extends Cubit<ProfileState> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> getMyProfile({String period = '1'}) async {
|
Future<void> getMyProfile({String period = '1'}) async {
|
||||||
|
myProfileCubit = this;
|
||||||
emit(const ProfileState.loading());
|
emit(const ProfileState.loading());
|
||||||
_currentPeriod = period;
|
_currentPeriod = period;
|
||||||
try {
|
try {
|
||||||
@@ -74,6 +78,22 @@ class ProfileCubit extends Cubit<ProfileState> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Reloads only the blog posts list without resetting the whole profile screen.
|
||||||
|
Future<void> reloadBlogPosts() async {
|
||||||
|
final currentState = state;
|
||||||
|
if (currentState is! ProfileStateLoaded) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
final blogPosts = await profileRepository.getBlogPostsByAuthorSlug(slug: currentState.profile.slug);
|
||||||
|
emit(currentState.copyWith(blogPosts: blogPosts));
|
||||||
|
} catch (e, st) {
|
||||||
|
await reportFrom(e, st, this);
|
||||||
|
log(e.toString(), stackTrace: st);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void updateFeedBlogPostList(FeedBlogPostModel feedBlogPostModel, bool wasLikedByMe) {
|
void updateFeedBlogPostList(FeedBlogPostModel feedBlogPostModel, bool wasLikedByMe) {
|
||||||
List<FeedBlogPostModel> feedBlogPostList = [...(state as ProfileStateLoaded).blogPosts];
|
List<FeedBlogPostModel> feedBlogPostList = [...(state as ProfileStateLoaded).blogPosts];
|
||||||
final int index = feedBlogPostList.indexOf(feedBlogPostModel);
|
final int index = feedBlogPostList.indexOf(feedBlogPostModel);
|
||||||
|
|||||||
@@ -436,8 +436,11 @@ class _ProfileScreenState extends State<ProfileScreen> {
|
|||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||||
child: InkWell(
|
child: InkWell(
|
||||||
onTap: () {
|
onTap: () async {
|
||||||
GoRouter.of(context).pushNamed('blogPostCreateScreen');
|
await GoRouter.of(context).pushNamed('blogPostCreateScreen');
|
||||||
|
if (mounted) {
|
||||||
|
await profileCubit.reloadBlogPosts();
|
||||||
|
}
|
||||||
},
|
},
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
child: Ink(
|
child: Ink(
|
||||||
|
|||||||
@@ -1,19 +1,21 @@
|
|||||||
import 'package:cached_network_image/cached_network_image.dart';
|
|
||||||
import 'package:dismissible_page/dismissible_page.dart';
|
import 'package:dismissible_page/dismissible_page.dart';
|
||||||
import 'package:eatvio_app/core/app_config.dart';
|
import 'package:eatvio_app/core/app_config.dart';
|
||||||
import 'package:eatvio_app/core/enums/complained_content.dart';
|
import 'package:eatvio_app/core/enums/complained_content.dart';
|
||||||
import 'package:eatvio_app/core/locator_service.dart';
|
import 'package:eatvio_app/core/locator_service.dart';
|
||||||
|
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
||||||
import 'package:eatvio_app/core/utils/date_time_utils.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/custom_popup.dart';
|
||||||
import 'package:eatvio_app/core/widgets/feed_icon_button.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/chat/presentation/pages/photo_view_screen.dart';
|
||||||
|
import 'package:eatvio_app/features/feed/models/feed_blog_post_model.dart';
|
||||||
|
import 'package:eatvio_app/features/feed/models/post_feed_mapper.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_cubit.dart';
|
||||||
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
import 'package:eatvio_app/features/feed/presentation/widgets/blog_post_media_content.dart';
|
||||||
import 'package:eatvio_app/features/profile/presentation/bloc/profile_cubit/profile_cubit.dart';
|
import 'package:eatvio_app/features/profile/presentation/bloc/profile_cubit/profile_cubit.dart';
|
||||||
import 'package:eatvio_app/features/profile/presentation/bloc/profile_cubit/profile_state.dart';
|
import 'package:eatvio_app/features/profile/presentation/bloc/profile_cubit/profile_state.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:eatvio_app/core/widgets/markdown_html_widget.dart';
|
|
||||||
import 'package:flutter_svg/flutter_svg.dart';
|
import 'package:flutter_svg/flutter_svg.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:share_plus/share_plus.dart';
|
import 'package:share_plus/share_plus.dart';
|
||||||
@@ -22,7 +24,11 @@ class ProfileBlogPostWidget extends StatefulWidget {
|
|||||||
final int index;
|
final int index;
|
||||||
final ProfileCubit profileCubit;
|
final ProfileCubit profileCubit;
|
||||||
|
|
||||||
const ProfileBlogPostWidget({super.key, required this.index, required this.profileCubit});
|
const ProfileBlogPostWidget({
|
||||||
|
super.key,
|
||||||
|
required this.index,
|
||||||
|
required this.profileCubit,
|
||||||
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<ProfileBlogPostWidget> createState() => _ProfileBlogPostWidgetState();
|
State<ProfileBlogPostWidget> createState() => _ProfileBlogPostWidgetState();
|
||||||
@@ -31,221 +37,289 @@ class ProfileBlogPostWidget extends StatefulWidget {
|
|||||||
class _ProfileBlogPostWidgetState extends State<ProfileBlogPostWidget> {
|
class _ProfileBlogPostWidgetState extends State<ProfileBlogPostWidget> {
|
||||||
late bool isLiked;
|
late bool isLiked;
|
||||||
late int totalLikes;
|
late int totalLikes;
|
||||||
|
int currentGalleryIndex = 0;
|
||||||
|
|
||||||
|
void _openGallery(List<String> imageUrls, int index, {Object? heroTag}) {
|
||||||
|
context.pushTransparentRoute(
|
||||||
|
rootNavigator: true,
|
||||||
|
PhotoViewScreen(imageUrls: imageUrls, index: index, heroTag: heroTag),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return BlocBuilder<ProfileCubit, ProfileState>(
|
return BlocBuilder<ProfileCubit, ProfileState>(
|
||||||
bloc: widget.profileCubit,
|
bloc: widget.profileCubit,
|
||||||
builder: (BuildContext context, ProfileState state) {
|
builder: (BuildContext context, ProfileState state) {
|
||||||
if (state is ProfileStateLoaded) {
|
if (state is! ProfileStateLoaded) {
|
||||||
isLiked = state.blogPosts[widget.index].wasLikedByMe;
|
return const SizedBox.shrink();
|
||||||
totalLikes = state.blogPosts[widget.index].totalLikes;
|
}
|
||||||
return InkWell(
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
final FeedBlogPostModel post = state.blogPosts[widget.index];
|
||||||
onTap: () => GoRouter.of(context).pushNamed('blogPostScreen', extra: state.blogPosts[widget.index].id),
|
isLiked = post.wasLikedByMe;
|
||||||
child: Ink(
|
totalLikes = post.totalLikes;
|
||||||
decoration: BoxDecoration(borderRadius: BorderRadius.circular(12), color: Colors.white),
|
final imageUrl = PostFeedMapper.resolveMediaUrl(post.image);
|
||||||
child: Column(
|
final embedUrl = post.videoEmbedUrl?.trim();
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
return InkWell(
|
||||||
children: [
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
onTap: () =>
|
||||||
|
GoRouter.of(context).pushNamed('blogPostScreen', extra: post.id),
|
||||||
|
child: Ink(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
CircleAvatar(
|
||||||
|
radius: 20,
|
||||||
|
backgroundColor: Colors.black12,
|
||||||
|
foregroundImage: NetworkImage(
|
||||||
|
post.author.authorProfileIcon.contains('https')
|
||||||
|
? post.author.authorProfileIcon
|
||||||
|
: sl<AppConfig>().baseUrl +
|
||||||
|
post.author.authorProfileIcon,
|
||||||
|
),
|
||||||
|
backgroundImage: const AssetImage(
|
||||||
|
'assets/images/no_avatar.png',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
post.author.authorName,
|
||||||
|
style: const TextStyle(
|
||||||
|
height: 1,
|
||||||
|
color: Colors.black87,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
'${calculateDifference(post.createdAt)} в ${post.createdAt.hour.toString().padLeft(2, '0')}:${post.createdAt.minute.toString().padLeft(2, '0')}',
|
||||||
|
style: const TextStyle(
|
||||||
|
height: 1,
|
||||||
|
color: Colors.black45,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
CustomPopup(
|
||||||
|
isAuthor: post.author.authorIsMe,
|
||||||
|
complainedContentType: ComplainedContent.posts,
|
||||||
|
path: 'blog_post/${post.id}',
|
||||||
|
id: post.id,
|
||||||
|
feedBlogPostModel: post,
|
||||||
|
userSlug: post.author.authorSlug,
|
||||||
|
onPostsChanged: widget.profileCubit.reloadBlogPosts,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (post.title.isNotEmpty) ...[
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||||
child: Row(
|
child: Text(
|
||||||
children: [
|
post.title,
|
||||||
CircleAvatar(
|
style: const TextStyle(
|
||||||
radius: 20,
|
fontWeight: FontWeight.w600,
|
||||||
backgroundColor: Colors.black12,
|
fontSize: 18,
|
||||||
foregroundImage: NetworkImage(
|
color: Colors.black87,
|
||||||
state.blogPosts[widget.index].author.authorProfileIcon.contains('https')
|
|
||||||
? state.blogPosts[widget.index].author.authorProfileIcon
|
|
||||||
: sl<AppConfig>().baseUrl + state.blogPosts[widget.index].author.authorProfileIcon,
|
|
||||||
),
|
|
||||||
backgroundImage: const AssetImage('assets/images/no_avatar.png'),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
state.blogPosts[widget.index].author.authorName,
|
|
||||||
style: const TextStyle(height: 1, color: Colors.black87, fontWeight: FontWeight.w500),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 4),
|
|
||||||
Text(
|
|
||||||
'${calculateDifference(state.blogPosts[widget.index].createdAt)} в ${state.blogPosts[widget.index].createdAt.hour.toString().padLeft(2, '0')}:${state.blogPosts[widget.index].createdAt.minute.toString().padLeft(2, '0')}',
|
|
||||||
style: const TextStyle(height: 1, color: Colors.black45),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const Spacer(),
|
|
||||||
CustomPopup(
|
|
||||||
isAuthor: state.blogPosts[widget.index].author.authorIsMe,
|
|
||||||
complainedContentType: ComplainedContent.posts,
|
|
||||||
path: 'blog_post/${(state.blogPosts[widget.index].id)}',
|
|
||||||
id: state.blogPosts[widget.index].id,
|
|
||||||
feedBlogPostModel: state.blogPosts[widget.index],
|
|
||||||
userSlug: state.blogPosts[widget.index].author.authorSlug,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (state.blogPosts[widget.index].title.isNotEmpty) ...[
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
|
||||||
child: Text(
|
|
||||||
state.blogPosts[widget.index].title,
|
|
||||||
style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 18, color: Colors.black87),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
if (state.blogPosts[widget.index].title.isEmpty) const SizedBox(height: 8),
|
],
|
||||||
if (state.blogPosts[widget.index].title.isNotEmpty && state.blogPosts[widget.index].description.isNotEmpty) const SizedBox(height: 8),
|
if (post.title.isEmpty) const SizedBox(height: 8),
|
||||||
if (state.blogPosts[widget.index].description.isNotEmpty)
|
if (post.title.isNotEmpty && post.description.isNotEmpty)
|
||||||
Padding(
|
const SizedBox(height: 8),
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
if (post.description.isNotEmpty)
|
||||||
child: Builder(
|
Padding(
|
||||||
builder: (context) {
|
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||||
String description = state.blogPosts[widget.index].description;
|
child: MarkdownHtmlWidget(
|
||||||
final isLong = description.length > 200;
|
markdownText: _truncateDescription(post.description),
|
||||||
|
fontSize: 16,
|
||||||
if (isLong) {
|
textColor: Colors.black87,
|
||||||
// Умная обрезка: обрезаем markdown текст, не ломая синтаксис
|
),
|
||||||
int cutPoint = 200;
|
),
|
||||||
|
if (post.description.length > 200)
|
||||||
// Проверяем, есть ли незакрытые markdown теги
|
const Padding(
|
||||||
String preview = description.substring(0, cutPoint);
|
padding: EdgeInsets.symmetric(horizontal: 12),
|
||||||
|
child: Text(
|
||||||
// Считаем открывающие и закрывающие теги
|
'Показать ещё',
|
||||||
int boldOpen = (preview.split('**').length - 1) ~/ 2;
|
style: TextStyle(
|
||||||
int boldClose = preview.split('**').length - 1 - boldOpen * 2;
|
height: 1,
|
||||||
bool hasUnclosedBold = boldOpen > boldClose;
|
color: Color(0xFF8B8B8B),
|
||||||
|
fontSize: 14,
|
||||||
// Если есть незакрытый bold, ищем закрытие
|
fontWeight: FontWeight.w600,
|
||||||
if (hasUnclosedBold) {
|
),
|
||||||
int searchEnd = (cutPoint + 100).clamp(0, description.length);
|
),
|
||||||
int boldClosePos = description.indexOf('**', cutPoint);
|
),
|
||||||
if (boldClosePos != -1 && boldClosePos < searchEnd) {
|
if (post.title.isNotEmpty || post.description.isNotEmpty)
|
||||||
cutPoint = boldClosePos + 2;
|
const SizedBox(height: 8),
|
||||||
} else {
|
BlogPostMediaContent(
|
||||||
// Если не нашли, обрезаем до последнего пробела
|
galleryImages: post.images,
|
||||||
cutPoint = description.lastIndexOf(' ', 200);
|
imageUrl: imageUrl,
|
||||||
if (cutPoint == -1 || cutPoint < 150) cutPoint = 200;
|
videoEmbedUrl: embedUrl != null && embedUrl.isNotEmpty
|
||||||
}
|
? embedUrl
|
||||||
} else {
|
: null,
|
||||||
// Обрезаем до последнего пробела для красоты
|
videoUrlThumb: post.videoUrlThumb != null
|
||||||
cutPoint = description.lastIndexOf(' ', cutPoint);
|
? PostFeedMapper.resolveMediaUrl(post.videoUrlThumb)
|
||||||
if (cutPoint == -1 || cutPoint < 150) cutPoint = 200;
|
: null,
|
||||||
}
|
currentGalleryIndex: currentGalleryIndex,
|
||||||
|
heroTag: 'profile-blog-post-${post.id}-cover',
|
||||||
description = description.substring(0, cutPoint);
|
onOpenGallery: (urls, index) => _openGallery(
|
||||||
}
|
urls,
|
||||||
|
index,
|
||||||
return MarkdownHtmlWidget(
|
heroTag: urls.length == 1
|
||||||
markdownText: description,
|
? 'profile-blog-post-${post.id}-cover'
|
||||||
fontSize: 16,
|
: null,
|
||||||
textColor: Colors.black87,
|
),
|
||||||
|
onGalleryChanged: (index) {
|
||||||
|
setState(() {
|
||||||
|
currentGalleryIndex = index;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
FeedIconButton(
|
||||||
|
icon: isLiked
|
||||||
|
? Icons.favorite_rounded
|
||||||
|
: Icons.favorite_border_rounded,
|
||||||
|
text: totalLikes.toString(),
|
||||||
|
iconColor: isLiked
|
||||||
|
? Colors.red.withValues(alpha: 0.8)
|
||||||
|
: null,
|
||||||
|
backgroundColor: isLiked
|
||||||
|
? Colors.red.withValues(alpha: 0.1)
|
||||||
|
: null,
|
||||||
|
textColor: isLiked
|
||||||
|
? Colors.red.withValues(alpha: 0.8)
|
||||||
|
: null,
|
||||||
|
onTap: () async {
|
||||||
|
setState(() {
|
||||||
|
isLiked = !isLiked;
|
||||||
|
isLiked ? totalLikes++ : totalLikes--;
|
||||||
|
});
|
||||||
|
isLiked
|
||||||
|
? await context.read<FeedCubit>().likePost(
|
||||||
|
post.id,
|
||||||
|
)
|
||||||
|
: await context.read<FeedCubit>().unlikedPost(
|
||||||
|
post.id,
|
||||||
|
);
|
||||||
|
widget.profileCubit.updateFeedBlogPostList(
|
||||||
|
post,
|
||||||
|
isLiked ? false : true,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
const SizedBox(width: 8),
|
||||||
if (state.blogPosts[widget.index].description.length > 200)
|
FeedIconButton(
|
||||||
const Padding(
|
svgIcon: SvgPicture.asset(
|
||||||
padding: EdgeInsets.symmetric(horizontal: 12),
|
'assets/icons/comment.svg',
|
||||||
child: Text(
|
height: 20,
|
||||||
'Показать ещё',
|
width: 20,
|
||||||
style: TextStyle(height: 1, color: Color(0xFF8B8B8B), fontSize: 14, fontWeight: FontWeight.w600),
|
colorFilter: ColorFilter.mode(
|
||||||
),
|
appTheme.gray600,
|
||||||
),
|
BlendMode.srcIn,
|
||||||
if (state.blogPosts[widget.index].title.isNotEmpty || state.blogPosts[widget.index].description.isNotEmpty) const SizedBox(height: 8),
|
|
||||||
GestureDetector(
|
|
||||||
onTap: () {
|
|
||||||
context.pushTransparentRoute(
|
|
||||||
rootNavigator: true,
|
|
||||||
PhotoViewScreen(imageUrls: ['${sl<AppConfig>().baseUrl}/storage/${state.blogPosts[widget.index].image}'], index: 0),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
child: Hero(
|
|
||||||
tag: '${sl<AppConfig>().baseUrl}/storage/${state.blogPosts[widget.index].image}',
|
|
||||||
child: CachedNetworkImage(
|
|
||||||
fit: BoxFit.fitWidth,
|
|
||||||
width: MediaQuery.of(context).size.width,
|
|
||||||
imageUrl: '${sl<AppConfig>().baseUrl}/storage/${state.blogPosts[widget.index].image}',
|
|
||||||
errorWidget: (context, str, object) => const SizedBox.shrink(),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
FeedIconButton(
|
|
||||||
icon: isLiked ? Icons.favorite_rounded : Icons.favorite_border_rounded,
|
|
||||||
text: totalLikes.toString(),
|
|
||||||
iconColor: isLiked ? Colors.red.withValues(alpha: 0.8) : null,
|
|
||||||
backgroundColor: isLiked ? Colors.red.withValues(alpha: 0.1) : null,
|
|
||||||
textColor: isLiked ? Colors.red.withValues(alpha: 0.8) : null,
|
|
||||||
onTap: () async {
|
|
||||||
setState(() {
|
|
||||||
isLiked = !isLiked;
|
|
||||||
isLiked ? totalLikes++ : totalLikes--;
|
|
||||||
});
|
|
||||||
isLiked
|
|
||||||
? await context.read<FeedCubit>().likePost(state.blogPosts[widget.index].id)
|
|
||||||
: await context.read<FeedCubit>().unlikedPost(state.blogPosts[widget.index].id);
|
|
||||||
widget.profileCubit.updateFeedBlogPostList(state.blogPosts[widget.index], isLiked ? false : true);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
FeedIconButton(
|
|
||||||
svgIcon: SvgPicture.asset(
|
|
||||||
'assets/icons/comment.svg',
|
|
||||||
height: 20,
|
|
||||||
width: 20,
|
|
||||||
colorFilter: ColorFilter.mode(appTheme.gray600, BlendMode.srcIn),
|
|
||||||
),
|
),
|
||||||
text: state.blogPosts[widget.index].totalComments.toString(),
|
|
||||||
onTap: () => GoRouter.of(context).pushNamed('blogPostScreen', extra: state.blogPosts[widget.index].id),
|
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
text: post.totalComments.toString(),
|
||||||
FeedIconButton(
|
onTap: () => GoRouter.of(
|
||||||
svgIcon: SvgPicture.asset(
|
context,
|
||||||
height: 20,
|
).pushNamed('blogPostScreen', extra: post.id),
|
||||||
width: 20,
|
),
|
||||||
'assets/icons/share.svg',
|
const SizedBox(width: 8),
|
||||||
colorFilter: ColorFilter.mode(appTheme.gray600, BlendMode.srcIn),
|
FeedIconButton(
|
||||||
|
svgIcon: SvgPicture.asset(
|
||||||
|
height: 20,
|
||||||
|
width: 20,
|
||||||
|
'assets/icons/share.svg',
|
||||||
|
colorFilter: ColorFilter.mode(
|
||||||
|
appTheme.gray600,
|
||||||
|
BlendMode.srcIn,
|
||||||
),
|
),
|
||||||
onTap: () {
|
),
|
||||||
final box = context.findRenderObject() as RenderBox?;
|
onTap: () {
|
||||||
SharePlus.instance.share(
|
final box = context.findRenderObject() as RenderBox?;
|
||||||
ShareParams(
|
SharePlus.instance.share(
|
||||||
uri: Uri.parse('${sl<AppConfig>().baseUrl}/ru/blog_post/${state.blogPosts[widget.index].id}'),
|
ShareParams(
|
||||||
sharePositionOrigin: box!.localToGlobal(Offset.zero) & box.size,
|
uri: Uri.parse(
|
||||||
|
'${sl<AppConfig>().baseUrl}/ru/blog_post/${post.id}',
|
||||||
),
|
),
|
||||||
);
|
sharePositionOrigin:
|
||||||
},
|
box!.localToGlobal(Offset.zero) & box.size,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
Icon(
|
||||||
|
Icons.visibility_rounded,
|
||||||
|
size: 16,
|
||||||
|
color: appTheme.gray500,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Text(
|
||||||
|
post.totalViews.toString(),
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: appTheme.gray500,
|
||||||
|
fontWeight: FontWeight.w400,
|
||||||
),
|
),
|
||||||
const Spacer(),
|
),
|
||||||
Icon(Icons.visibility_rounded, size: 16, color: appTheme.gray500),
|
],
|
||||||
const SizedBox(width: 4),
|
|
||||||
Text(
|
|
||||||
state.blogPosts[widget.index].totalViews.toString(),
|
|
||||||
style: TextStyle(fontSize: 12, color: appTheme.gray500, fontWeight: FontWeight.w400),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
),
|
||||||
],
|
const SizedBox(height: 8),
|
||||||
),
|
],
|
||||||
),
|
),
|
||||||
);
|
),
|
||||||
}
|
);
|
||||||
return const SizedBox.shrink();
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String _truncateDescription(String description) {
|
||||||
|
final isLong = description.length > 200;
|
||||||
|
if (!isLong) return description;
|
||||||
|
|
||||||
|
var cutPoint = 200;
|
||||||
|
final preview = description.substring(0, cutPoint);
|
||||||
|
final boldOpen = (preview.split('**').length - 1) ~/ 2;
|
||||||
|
final boldClose = preview.split('**').length - 1 - boldOpen * 2;
|
||||||
|
final hasUnclosedBold = boldOpen > boldClose;
|
||||||
|
|
||||||
|
if (hasUnclosedBold) {
|
||||||
|
final searchEnd = (cutPoint + 100).clamp(0, description.length);
|
||||||
|
final 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
return description.substring(0, cutPoint);
|
||||||
|
}
|
||||||
|
|||||||
@@ -238,7 +238,10 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16) + const EdgeInsets.only(bottom: 8),
|
padding: const EdgeInsets.symmetric(horizontal: 16) + const EdgeInsets.only(bottom: 8),
|
||||||
child: InkWell(
|
child: InkWell(
|
||||||
onTap: () => GoRouter.of(context).pushNamed('becomeConsultantScreen'),
|
onTap: () => GoRouter.of(context).pushNamed(
|
||||||
|
'becomeConsultantScreen',
|
||||||
|
extra: {'slug': state.profile.slug},
|
||||||
|
),
|
||||||
borderRadius: BorderRadius.circular(20),
|
borderRadius: BorderRadius.circular(20),
|
||||||
child: Ink(
|
child: Ink(
|
||||||
height: 52,
|
height: 52,
|
||||||
|
|||||||
@@ -27,24 +27,27 @@ class MessageLookup extends MessageLookupByLibrary {
|
|||||||
static String m2(planName, name) =>
|
static String m2(planName, name) =>
|
||||||
"План «${planName}» с пользователем ${name} начался";
|
"План «${planName}» с пользователем ${name} начался";
|
||||||
|
|
||||||
static String m3(step) => "шаг ${step} из 2";
|
static String m3(percent, amount) =>
|
||||||
|
"Discount ${percent}% — you save ${amount} ₽";
|
||||||
|
|
||||||
static String m4(planName, name) =>
|
static String m4(step) => "шаг ${step} из 2";
|
||||||
|
|
||||||
|
static String m5(planName, name) =>
|
||||||
"Завтра начинается план «${planName}» с пользователем ${name}";
|
"Завтра начинается план «${planName}» с пользователем ${name}";
|
||||||
|
|
||||||
static String m5(planName) =>
|
static String m6(planName) =>
|
||||||
"Завтра начнется прохождение плана «${planName}»";
|
"Завтра начнется прохождение плана «${planName}»";
|
||||||
|
|
||||||
static String m6(name, planName) =>
|
static String m7(name, planName) =>
|
||||||
"Пользователь ${name} отменил подписку на план «${planName}»";
|
"Пользователь ${name} отменил подписку на план «${planName}»";
|
||||||
|
|
||||||
static String m7(name, planName) =>
|
static String m8(name, planName) =>
|
||||||
"Пользователь ${name} оплатил план «${planName}»";
|
"Пользователь ${name} оплатил план «${planName}»";
|
||||||
|
|
||||||
static String m8(name, planName) =>
|
static String m9(name, planName) =>
|
||||||
"Пользователь ${name} подписался на план «${planName}»";
|
"Пользователь ${name} подписался на план «${planName}»";
|
||||||
|
|
||||||
static String m9(planName) => "Вы отменили подписку на план «${planName}»";
|
static String m10(planName) => "Вы отменили подписку на план «${planName}»";
|
||||||
|
|
||||||
final messages = _notInlinedMessages(_notInlinedMessages);
|
final messages = _notInlinedMessages(_notInlinedMessages);
|
||||||
static Map<String, Function> _notInlinedMessages(_) => <String, Function>{
|
static Map<String, Function> _notInlinedMessages(_) => <String, Function>{
|
||||||
@@ -134,6 +137,9 @@ class MessageLookup extends MessageLookupByLibrary {
|
|||||||
"Средние показатели давления:",
|
"Средние показатели давления:",
|
||||||
),
|
),
|
||||||
"back_to_chat": MessageLookupByLibrary.simpleMessage("Вернуться в чат"),
|
"back_to_chat": MessageLookupByLibrary.simpleMessage("Вернуться в чат"),
|
||||||
|
"blog_post_image_min_size_error": MessageLookupByLibrary.simpleMessage(
|
||||||
|
"Минимальный размер изображения — 500×500 px",
|
||||||
|
),
|
||||||
"bodymass_chart": MessageLookupByLibrary.simpleMessage("График ИМТ"),
|
"bodymass_chart": MessageLookupByLibrary.simpleMessage("График ИМТ"),
|
||||||
"by_default": MessageLookupByLibrary.simpleMessage("По умолчанию"),
|
"by_default": MessageLookupByLibrary.simpleMessage("По умолчанию"),
|
||||||
"calls_for_bullying": MessageLookupByLibrary.simpleMessage(
|
"calls_for_bullying": MessageLookupByLibrary.simpleMessage(
|
||||||
@@ -715,10 +721,21 @@ class MessageLookup extends MessageLookupByLibrary {
|
|||||||
"Разница в ценах, это комиссия Итвио",
|
"Разница в ценах, это комиссия Итвио",
|
||||||
),
|
),
|
||||||
"profile": MessageLookupByLibrary.simpleMessage("Профиль"),
|
"profile": MessageLookupByLibrary.simpleMessage("Профиль"),
|
||||||
|
"promo_applied_subtitle": MessageLookupByLibrary.simpleMessage(
|
||||||
|
"The discount will be applied at checkout",
|
||||||
|
),
|
||||||
|
"promo_applied_title": MessageLookupByLibrary.simpleMessage(
|
||||||
|
"Promo code applied!",
|
||||||
|
),
|
||||||
"promo_code": MessageLookupByLibrary.simpleMessage("Promo code"),
|
"promo_code": MessageLookupByLibrary.simpleMessage("Promo code"),
|
||||||
|
"promo_discount_hint": m3,
|
||||||
"promo_invalid": MessageLookupByLibrary.simpleMessage(
|
"promo_invalid": MessageLookupByLibrary.simpleMessage(
|
||||||
"Promo code not found or expired",
|
"Promo code not found or expired",
|
||||||
),
|
),
|
||||||
|
"promo_remove": MessageLookupByLibrary.simpleMessage("Remove promo code"),
|
||||||
|
"promo_remove_failed": MessageLookupByLibrary.simpleMessage(
|
||||||
|
"Could not remove the promo code. Try again later",
|
||||||
|
),
|
||||||
"prostitution": MessageLookupByLibrary.simpleMessage("Проституция"),
|
"prostitution": MessageLookupByLibrary.simpleMessage("Проституция"),
|
||||||
"proteins": MessageLookupByLibrary.simpleMessage("Белки"),
|
"proteins": MessageLookupByLibrary.simpleMessage("Белки"),
|
||||||
"public_diet_state": MessageLookupByLibrary.simpleMessage("Публичная"),
|
"public_diet_state": MessageLookupByLibrary.simpleMessage("Публичная"),
|
||||||
@@ -809,7 +826,7 @@ class MessageLookup extends MessageLookupByLibrary {
|
|||||||
"step_by_step_photo_recipe": MessageLookupByLibrary.simpleMessage(
|
"step_by_step_photo_recipe": MessageLookupByLibrary.simpleMessage(
|
||||||
"Пошаговый фоторецепт",
|
"Пошаговый фоторецепт",
|
||||||
),
|
),
|
||||||
"step_widget": m3,
|
"step_widget": m4,
|
||||||
"successfully_published": MessageLookupByLibrary.simpleMessage(
|
"successfully_published": MessageLookupByLibrary.simpleMessage(
|
||||||
"успешно опубликован",
|
"успешно опубликован",
|
||||||
),
|
),
|
||||||
@@ -834,8 +851,8 @@ class MessageLookup extends MessageLookupByLibrary {
|
|||||||
"toOffer": MessageLookupByLibrary.simpleMessage("Предложить"),
|
"toOffer": MessageLookupByLibrary.simpleMessage("Предложить"),
|
||||||
"to_draft": MessageLookupByLibrary.simpleMessage("В черновик"),
|
"to_draft": MessageLookupByLibrary.simpleMessage("В черновик"),
|
||||||
"to_order": MessageLookupByLibrary.simpleMessage("Заказать"),
|
"to_order": MessageLookupByLibrary.simpleMessage("Заказать"),
|
||||||
"tommorowPlanWithUserWillStart": m4,
|
"tommorowPlanWithUserWillStart": m5,
|
||||||
"tomorrowYourPlanWillStart": m5,
|
"tomorrowYourPlanWillStart": m6,
|
||||||
"try_again": MessageLookupByLibrary.simpleMessage("Попробовать снова"),
|
"try_again": MessageLookupByLibrary.simpleMessage("Попробовать снова"),
|
||||||
"try_again_2": MessageLookupByLibrary.simpleMessage("Попробуйте ещё раз"),
|
"try_again_2": MessageLookupByLibrary.simpleMessage("Попробуйте ещё раз"),
|
||||||
"typeAmoutOfReturn": MessageLookupByLibrary.simpleMessage(
|
"typeAmoutOfReturn": MessageLookupByLibrary.simpleMessage(
|
||||||
@@ -869,7 +886,7 @@ class MessageLookup extends MessageLookupByLibrary {
|
|||||||
"Верхние показатели давления:",
|
"Верхние показатели давления:",
|
||||||
),
|
),
|
||||||
"upper": MessageLookupByLibrary.simpleMessage("Верхнее"),
|
"upper": MessageLookupByLibrary.simpleMessage("Верхнее"),
|
||||||
"userCanceldThePlanSubscription": m6,
|
"userCanceldThePlanSubscription": m7,
|
||||||
"userConfirmedPlanCompletion": MessageLookupByLibrary.simpleMessage(
|
"userConfirmedPlanCompletion": MessageLookupByLibrary.simpleMessage(
|
||||||
"Пользователь подтвердил прохождения плана. В ближайшее время Вы получите",
|
"Пользователь подтвердил прохождения плана. В ближайшее время Вы получите",
|
||||||
),
|
),
|
||||||
@@ -877,8 +894,8 @@ class MessageLookup extends MessageLookupByLibrary {
|
|||||||
MessageLookupByLibrary.simpleMessage(
|
MessageLookupByLibrary.simpleMessage(
|
||||||
"Пользователь подтвердил прохождения плана.",
|
"Пользователь подтвердил прохождения плана.",
|
||||||
),
|
),
|
||||||
"userPaidThePlan": m7,
|
"userPaidThePlan": m8,
|
||||||
"userSubscribedToPlan": m8,
|
"userSubscribedToPlan": m9,
|
||||||
"user_authorization": MessageLookupByLibrary.simpleMessage(
|
"user_authorization": MessageLookupByLibrary.simpleMessage(
|
||||||
"Аутентификация",
|
"Аутентификация",
|
||||||
),
|
),
|
||||||
@@ -1008,7 +1025,7 @@ class MessageLookup extends MessageLookupByLibrary {
|
|||||||
"youCanComplain": MessageLookupByLibrary.simpleMessage(
|
"youCanComplain": MessageLookupByLibrary.simpleMessage(
|
||||||
"Если вас что-то не устроило, вы можете подать жалобу",
|
"Если вас что-то не устроило, вы можете подать жалобу",
|
||||||
),
|
),
|
||||||
"youCanceledSubscriptionToPlan": m9,
|
"youCanceledSubscriptionToPlan": m10,
|
||||||
"youShureYouWantToCancelOrder": MessageLookupByLibrary.simpleMessage(
|
"youShureYouWantToCancelOrder": MessageLookupByLibrary.simpleMessage(
|
||||||
"Вы уверены, что хотите отменить заказ?",
|
"Вы уверены, что хотите отменить заказ?",
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ typedef String MessageIfAbsent(String messageStr, List<dynamic> args);
|
|||||||
class MessageLookup extends MessageLookupByLibrary {
|
class MessageLookup extends MessageLookupByLibrary {
|
||||||
String get localeName => 'ru';
|
String get localeName => 'ru';
|
||||||
|
|
||||||
|
static String m3(percent, amount) =>
|
||||||
|
"Скидка ${percent}% — вы экономите ${amount} ₽";
|
||||||
|
|
||||||
final messages = _notInlinedMessages(_notInlinedMessages);
|
final messages = _notInlinedMessages(_notInlinedMessages);
|
||||||
static Map<String, Function> _notInlinedMessages(_) => <String, Function>{
|
static Map<String, Function> _notInlinedMessages(_) => <String, Function>{
|
||||||
"Moscow": MessageLookupByLibrary.simpleMessage("Москва"),
|
"Moscow": MessageLookupByLibrary.simpleMessage("Москва"),
|
||||||
@@ -52,6 +55,9 @@ class MessageLookup extends MessageLookupByLibrary {
|
|||||||
"average_pressure": MessageLookupByLibrary.simpleMessage(
|
"average_pressure": MessageLookupByLibrary.simpleMessage(
|
||||||
"Средние показатели давления:",
|
"Средние показатели давления:",
|
||||||
),
|
),
|
||||||
|
"blog_post_image_min_size_error": MessageLookupByLibrary.simpleMessage(
|
||||||
|
"Минимальный размер изображения — 500×500 px",
|
||||||
|
),
|
||||||
"bodymass_chart": MessageLookupByLibrary.simpleMessage("График ИМТ"),
|
"bodymass_chart": MessageLookupByLibrary.simpleMessage("График ИМТ"),
|
||||||
"by_default": MessageLookupByLibrary.simpleMessage("По умолчанию"),
|
"by_default": MessageLookupByLibrary.simpleMessage("По умолчанию"),
|
||||||
"calories": MessageLookupByLibrary.simpleMessage("Калории"),
|
"calories": MessageLookupByLibrary.simpleMessage("Калории"),
|
||||||
@@ -322,10 +328,21 @@ class MessageLookup extends MessageLookupByLibrary {
|
|||||||
"Разница в ценах, это комиссия Итвио",
|
"Разница в ценах, это комиссия Итвио",
|
||||||
),
|
),
|
||||||
"profile": MessageLookupByLibrary.simpleMessage("Профиль"),
|
"profile": MessageLookupByLibrary.simpleMessage("Профиль"),
|
||||||
|
"promo_applied_subtitle": MessageLookupByLibrary.simpleMessage(
|
||||||
|
"Скидка учтётся при оплате",
|
||||||
|
),
|
||||||
|
"promo_applied_title": MessageLookupByLibrary.simpleMessage(
|
||||||
|
"Промокод применён!",
|
||||||
|
),
|
||||||
"promo_code": MessageLookupByLibrary.simpleMessage("Промокод"),
|
"promo_code": MessageLookupByLibrary.simpleMessage("Промокод"),
|
||||||
|
"promo_discount_hint": m3,
|
||||||
"promo_invalid": MessageLookupByLibrary.simpleMessage(
|
"promo_invalid": MessageLookupByLibrary.simpleMessage(
|
||||||
"Промокод не найден или истёк",
|
"Промокод не найден или истёк",
|
||||||
),
|
),
|
||||||
|
"promo_remove": MessageLookupByLibrary.simpleMessage("Убрать промокод"),
|
||||||
|
"promo_remove_failed": MessageLookupByLibrary.simpleMessage(
|
||||||
|
"Не удалось убрать промокод. Попробуйте позже",
|
||||||
|
),
|
||||||
"proteins": MessageLookupByLibrary.simpleMessage("Белки"),
|
"proteins": MessageLookupByLibrary.simpleMessage("Белки"),
|
||||||
"public_diet_state": MessageLookupByLibrary.simpleMessage("Публичная"),
|
"public_diet_state": MessageLookupByLibrary.simpleMessage("Публичная"),
|
||||||
"publish": MessageLookupByLibrary.simpleMessage("Опубликовать"),
|
"publish": MessageLookupByLibrary.simpleMessage("Опубликовать"),
|
||||||
|
|||||||
@@ -2569,6 +2569,16 @@ class S {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `Минимальный размер изображения — 500×500 px`
|
||||||
|
String get blog_post_image_min_size_error {
|
||||||
|
return Intl.message(
|
||||||
|
'Минимальный размер изображения — 500×500 px',
|
||||||
|
name: 'blog_post_image_min_size_error',
|
||||||
|
desc: '',
|
||||||
|
args: [],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// `Информация о заказе`
|
/// `Информация о заказе`
|
||||||
String get infoAboutOrder {
|
String get infoAboutOrder {
|
||||||
return Intl.message(
|
return Intl.message(
|
||||||
@@ -4264,6 +4274,56 @@ class S {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `Promo code applied!`
|
||||||
|
String get promo_applied_title {
|
||||||
|
return Intl.message(
|
||||||
|
'Promo code applied!',
|
||||||
|
name: 'promo_applied_title',
|
||||||
|
desc: '',
|
||||||
|
args: [],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `The discount will be applied at checkout`
|
||||||
|
String get promo_applied_subtitle {
|
||||||
|
return Intl.message(
|
||||||
|
'The discount will be applied at checkout',
|
||||||
|
name: 'promo_applied_subtitle',
|
||||||
|
desc: '',
|
||||||
|
args: [],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `Remove promo code`
|
||||||
|
String get promo_remove {
|
||||||
|
return Intl.message(
|
||||||
|
'Remove promo code',
|
||||||
|
name: 'promo_remove',
|
||||||
|
desc: '',
|
||||||
|
args: [],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `Discount {percent}% — you save {amount} ₽`
|
||||||
|
String promo_discount_hint(int percent, String amount) {
|
||||||
|
return Intl.message(
|
||||||
|
'Discount $percent% — you save $amount ₽',
|
||||||
|
name: 'promo_discount_hint',
|
||||||
|
desc: '',
|
||||||
|
args: [percent, amount],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `Could not remove the promo code. Try again later`
|
||||||
|
String get promo_remove_failed {
|
||||||
|
return Intl.message(
|
||||||
|
'Could not remove the promo code. Try again later',
|
||||||
|
name: 'promo_remove_failed',
|
||||||
|
desc: '',
|
||||||
|
args: [],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// `Loading…`
|
/// `Loading…`
|
||||||
String get video_call_screen_loading_title {
|
String get video_call_screen_loading_title {
|
||||||
return Intl.message(
|
return Intl.message(
|
||||||
|
|||||||
@@ -317,6 +317,7 @@
|
|||||||
"could_not_publish_blog_post": "Не удалось опубликовать пост",
|
"could_not_publish_blog_post": "Не удалось опубликовать пост",
|
||||||
"could_not_update_blog_post": "Не удалось обновить пост",
|
"could_not_update_blog_post": "Не удалось обновить пост",
|
||||||
"could_not_delete_blog_post": "Не удалось удалить пост",
|
"could_not_delete_blog_post": "Не удалось удалить пост",
|
||||||
|
"blog_post_image_min_size_error": "Минимальный размер изображения — 500×500 px",
|
||||||
"infoAboutOrder": "Информация о заказе",
|
"infoAboutOrder": "Информация о заказе",
|
||||||
"to_order": "Заказать",
|
"to_order": "Заказать",
|
||||||
"can_not_choose_start_date": "Дату старта плана невозможно выбрать, пока Вы не привяжите карту",
|
"can_not_choose_start_date": "Дату старта плана невозможно выбрать, пока Вы не привяжите карту",
|
||||||
@@ -502,6 +503,17 @@
|
|||||||
"promo_code": "Promo code",
|
"promo_code": "Promo code",
|
||||||
"enter_promo_code": "Enter promo code",
|
"enter_promo_code": "Enter promo code",
|
||||||
"promo_invalid": "Promo code not found or expired",
|
"promo_invalid": "Promo code not found or expired",
|
||||||
|
"promo_applied_title": "Promo code applied!",
|
||||||
|
"promo_applied_subtitle": "The discount will be applied at checkout",
|
||||||
|
"promo_remove": "Remove promo code",
|
||||||
|
"promo_discount_hint": "Discount {percent}% — you save {amount} ₽",
|
||||||
|
"@promo_discount_hint": {
|
||||||
|
"placeholders": {
|
||||||
|
"percent": {"type": "int"},
|
||||||
|
"amount": {"type": "String"}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"promo_remove_failed": "Could not remove the promo code. Try again later",
|
||||||
"video_call_screen_loading_title": "Loading…",
|
"video_call_screen_loading_title": "Loading…",
|
||||||
"video_call_screen_connecting_title": "Connecting to\nthe consultation",
|
"video_call_screen_connecting_title": "Connecting to\nthe consultation",
|
||||||
"video_call_screen_connecting_subtitle": "This may take a few seconds",
|
"video_call_screen_connecting_subtitle": "This may take a few seconds",
|
||||||
|
|||||||
@@ -246,6 +246,17 @@
|
|||||||
"promo_code": "Промокод",
|
"promo_code": "Промокод",
|
||||||
"enter_promo_code": "Введите промокод",
|
"enter_promo_code": "Введите промокод",
|
||||||
"promo_invalid": "Промокод не найден или истёк",
|
"promo_invalid": "Промокод не найден или истёк",
|
||||||
|
"promo_applied_title": "Промокод применён!",
|
||||||
|
"promo_applied_subtitle": "Скидка учтётся при оплате",
|
||||||
|
"promo_remove": "Убрать промокод",
|
||||||
|
"promo_discount_hint": "Скидка {percent}% — вы экономите {amount} ₽",
|
||||||
|
"@promo_discount_hint": {
|
||||||
|
"placeholders": {
|
||||||
|
"percent": {"type": "int"},
|
||||||
|
"amount": {"type": "String"}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"promo_remove_failed": "Не удалось убрать промокод. Попробуйте позже",
|
||||||
"video_call_permission_microphone_description": "Чтобы вас слышали во время видеозвонка, разрешите доступ к микрофону. При отказе можно включить доступ в настройках устройства.",
|
"video_call_permission_microphone_description": "Чтобы вас слышали во время видеозвонка, разрешите доступ к микрофону. При отказе можно включить доступ в настройках устройства.",
|
||||||
"video_call_screen_loading_title": "Загрузка…",
|
"video_call_screen_loading_title": "Загрузка…",
|
||||||
"video_call_screen_connecting_title": "Подключение\nк консультации",
|
"video_call_screen_connecting_title": "Подключение\nк консультации",
|
||||||
@@ -259,5 +270,6 @@
|
|||||||
"video_call_close": "Закрыть",
|
"video_call_close": "Закрыть",
|
||||||
"video_call_notification_in_progress": "Звонок идёт",
|
"video_call_notification_in_progress": "Звонок идёт",
|
||||||
"video_call_notification_return": "Вернуться к звонку",
|
"video_call_notification_return": "Вернуться к звонку",
|
||||||
"video_call_notification_end": "Завершить звонок"
|
"video_call_notification_end": "Завершить звонок",
|
||||||
|
"blog_post_image_min_size_error": "Минимальный размер изображения — 500×500 px"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
name: eatvio_app
|
name: eatvio_app
|
||||||
description: A new Flutter project.
|
description: A new Flutter project.
|
||||||
version: 1.3.35+124
|
version: 1.3.42+131
|
||||||
publish_to: 'none'
|
publish_to: 'none'
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
@@ -82,6 +82,7 @@ dependencies:
|
|||||||
rxdart: ^0.27.7
|
rxdart: ^0.27.7
|
||||||
gpassword: ^1.0.0
|
gpassword: ^1.0.0
|
||||||
webview_flutter_platform_interface: ^2.14.0
|
webview_flutter_platform_interface: ^2.14.0
|
||||||
|
flutter_inappwebview: ^6.1.5
|
||||||
intl_utils: ^2.8.13
|
intl_utils: ^2.8.13
|
||||||
sentry_dio: ^9.10.0
|
sentry_dio: ^9.10.0
|
||||||
livekit_client: ^2.6.1
|
livekit_client: ^2.6.1
|
||||||
|
|||||||
@@ -7,6 +7,32 @@ import 'package:go_router/go_router.dart';
|
|||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
group('AppRouter', () {
|
group('AppRouter', () {
|
||||||
|
group('resolveDeepLinkTarget - blog_post', () {
|
||||||
|
test('ru/blog_post/{id} → /blog-post-screen?id=', () {
|
||||||
|
final target = AppRouter.resolveDeepLinkTarget(
|
||||||
|
Uri.parse('https://eatvio.ru/ru/blog_post/123'),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(target, '/blog-post-screen?id=123');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('en/blog_post/{id} → /blog-post-screen?id=', () {
|
||||||
|
final target = AppRouter.resolveDeepLinkTarget(
|
||||||
|
Uri.parse('https://eatvio.com/en/blog_post/456'),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(target, '/blog-post-screen?id=456');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('eatvio.com/blog_post/{id} without locale → /blog-post-screen?id=', () {
|
||||||
|
final target = AppRouter.resolveDeepLinkTarget(
|
||||||
|
Uri.parse('https://eatvio.com/blog_post/789'),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(target, '/blog-post-screen?id=789');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
group('resolveDeepLinkTarget - finance/alfa/return', () {
|
group('resolveDeepLinkTarget - finance/alfa/return', () {
|
||||||
const orderId = 'alfa_c29_k15138_t1712345678_r1234';
|
const orderId = 'alfa_c29_k15138_t1712345678_r1234';
|
||||||
|
|
||||||
|
|||||||
@@ -51,8 +51,11 @@ void main() {
|
|||||||
final blogPostRoute = routes.firstWhere(
|
final blogPostRoute = routes.firstWhere(
|
||||||
(route) => route is GoRoute && route.path == '/blog-post-screen',
|
(route) => route is GoRoute && route.path == '/blog-post-screen',
|
||||||
orElse: () => throw Exception('Route not found'),
|
orElse: () => throw Exception('Route not found'),
|
||||||
);
|
) as GoRoute;
|
||||||
expect(blogPostRoute, isA<GoRoute>());
|
expect(blogPostRoute, isA<GoRoute>());
|
||||||
|
expect(blogPostRoute.path, '/blog-post-screen');
|
||||||
|
expect(blogPostRoute.name, 'blogPostScreen');
|
||||||
|
expect(blogPostRoute.builder, isNotNull);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('aboutPlanScreen builder обрабатывает slug из query параметров', () {
|
test('aboutPlanScreen builder обрабатывает slug из query параметров', () {
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import 'package:eatvio_app/features/feed/repositories/feed_repository.dart';
|
|||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:mocktail/mocktail.dart';
|
import 'package:mocktail/mocktail.dart';
|
||||||
|
|
||||||
|
import '../../../../fixtures/feed_fixtures.dart';
|
||||||
|
|
||||||
class MockFeedRepository extends Mock implements FeedRepository {}
|
class MockFeedRepository extends Mock implements FeedRepository {}
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
@@ -29,21 +31,7 @@ void main() {
|
|||||||
|
|
||||||
test('getBlogPost - успешная загрузка поста', () async {
|
test('getBlogPost - успешная загрузка поста', () async {
|
||||||
const postId = 1;
|
const postId = 1;
|
||||||
final mockPost = FeedBlogPostModel(
|
final mockPost = FeedFixtures.feedBlogPostModel();
|
||||||
id: postId,
|
|
||||||
title: 'Test Post',
|
|
||||||
state: 'active',
|
|
||||||
image: '',
|
|
||||||
description: 'Test Content',
|
|
||||||
videoUrl: '',
|
|
||||||
createdAt: DateTime.now(),
|
|
||||||
updatedAt: DateTime.now(),
|
|
||||||
totalLikes: 0,
|
|
||||||
totalViews: 0,
|
|
||||||
totalComments: 0,
|
|
||||||
wasLikedByMe: false,
|
|
||||||
author: const Author(authorName: 'Test Author', authorSlug: 'test-author', authorProfileIcon: '', authorIsMe: false),
|
|
||||||
);
|
|
||||||
|
|
||||||
when(() => mockRepository.getBlogPost(id: postId)).thenAnswer((_) async => mockPost);
|
when(() => mockRepository.getBlogPost(id: postId)).thenAnswer((_) async => mockPost);
|
||||||
|
|
||||||
@@ -51,6 +39,8 @@ void main() {
|
|||||||
|
|
||||||
expect(cubit.state.isLoading, false);
|
expect(cubit.state.isLoading, false);
|
||||||
expect(cubit.state.blogPostModel, mockPost);
|
expect(cubit.state.blogPostModel, mockPost);
|
||||||
|
expect(cubit.state.blogPostModel?.images, hasLength(2));
|
||||||
|
expect(cubit.state.blogPostModel?.videoEmbedUrl, contains('rutube.ru/play/embed'));
|
||||||
verify(() => mockRepository.getBlogPost(id: postId)).called(1);
|
verify(() => mockRepository.getBlogPost(id: postId)).called(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -7,9 +7,8 @@ import 'package:eatvio_app/core/theme/theme_helper.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_cubit.dart';
|
||||||
import 'package:eatvio_app/features/blog_post/presentation/cubit/blog_post_state.dart';
|
import 'package:eatvio_app/features/blog_post/presentation/cubit/blog_post_state.dart';
|
||||||
import 'package:eatvio_app/features/blog_post/presentation/pages/blog_post_screen.dart';
|
import 'package:eatvio_app/features/blog_post/presentation/pages/blog_post_screen.dart';
|
||||||
import 'package:eatvio_app/features/feed/models/diets_pagination_model.dart';
|
|
||||||
import 'package:eatvio_app/features/feed/models/feed_blog_post_model.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_cubit.dart';
|
||||||
|
import 'package:eatvio_app/features/feed/presentation/widgets/blog_post_media_content.dart';
|
||||||
import 'package:eatvio_app/generated/l10n.dart';
|
import 'package:eatvio_app/generated/l10n.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
@@ -21,6 +20,7 @@ import 'package:mockito/annotations.dart';
|
|||||||
import 'package:mockito/mockito.dart';
|
import 'package:mockito/mockito.dart';
|
||||||
import 'package:network_image_mock/network_image_mock.dart';
|
import 'package:network_image_mock/network_image_mock.dart';
|
||||||
|
|
||||||
|
import '../../../../fixtures/feed_fixtures.dart';
|
||||||
import 'blog_post_screen_smoke_test.mocks.dart';
|
import 'blog_post_screen_smoke_test.mocks.dart';
|
||||||
|
|
||||||
@GenerateNiceMocks([MockSpec<BlogPostCubit>(), MockSpec<FeedCubit>(), MockSpec<CommentsCubit>()])
|
@GenerateNiceMocks([MockSpec<BlogPostCubit>(), MockSpec<FeedCubit>(), MockSpec<CommentsCubit>()])
|
||||||
@@ -167,21 +167,7 @@ void main() {
|
|||||||
final blogPostCubit = MockBlogPostCubit();
|
final blogPostCubit = MockBlogPostCubit();
|
||||||
final feedCubit = MockFeedCubit();
|
final feedCubit = MockFeedCubit();
|
||||||
final commentsCubit = MockCommentsCubit();
|
final commentsCubit = MockCommentsCubit();
|
||||||
final blogPostModel = FeedBlogPostModel(
|
final blogPostModel = FeedFixtures.feedBlogPostModel();
|
||||||
id: 1,
|
|
||||||
title: 'Test Post Title',
|
|
||||||
description: 'Test post description',
|
|
||||||
image: 'https://example.com/image.jpg',
|
|
||||||
state: 'published',
|
|
||||||
author: const Author(authorSlug: 'author-1', authorName: 'Author Name', authorProfileIcon: 'https://example.com/icon.jpg', authorIsMe: false),
|
|
||||||
videoUrl: '',
|
|
||||||
createdAt: DateTime(2024, 1, 15),
|
|
||||||
updatedAt: DateTime(2024, 1, 15),
|
|
||||||
totalLikes: 10,
|
|
||||||
totalViews: 100,
|
|
||||||
totalComments: 5,
|
|
||||||
wasLikedByMe: false,
|
|
||||||
);
|
|
||||||
final state = BlogPostState(isLoading: false, blogPostModel: blogPostModel, error: null);
|
final state = BlogPostState(isLoading: false, blogPostModel: blogPostModel, error: null);
|
||||||
|
|
||||||
when(blogPostCubit.state).thenReturn(state);
|
when(blogPostCubit.state).thenReturn(state);
|
||||||
@@ -202,7 +188,8 @@ void main() {
|
|||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
expect(find.byType(BlogPostScreen), findsOneWidget);
|
expect(find.byType(BlogPostScreen), findsOneWidget);
|
||||||
expect(find.text('Test Post Title'), findsOneWidget);
|
expect(find.text('Заголовок'), findsOneWidget);
|
||||||
|
expect(find.byType(BlogPostMediaContent), findsOneWidget);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -211,21 +198,7 @@ void main() {
|
|||||||
final blogPostCubit = MockBlogPostCubit();
|
final blogPostCubit = MockBlogPostCubit();
|
||||||
final feedCubit = MockFeedCubit();
|
final feedCubit = MockFeedCubit();
|
||||||
final commentsCubit = MockCommentsCubit();
|
final commentsCubit = MockCommentsCubit();
|
||||||
final blogPostModel = FeedBlogPostModel(
|
final blogPostModel = FeedFixtures.feedBlogPostModel();
|
||||||
id: 1,
|
|
||||||
title: 'Test Post Title',
|
|
||||||
description: 'Test post description',
|
|
||||||
image: 'https://example.com/image.jpg',
|
|
||||||
state: 'published',
|
|
||||||
author: const Author(authorSlug: 'author-1', authorName: 'Author Name', authorProfileIcon: 'https://example.com/icon.jpg', authorIsMe: false),
|
|
||||||
videoUrl: '',
|
|
||||||
createdAt: DateTime(2024, 1, 15),
|
|
||||||
updatedAt: DateTime(2024, 1, 15),
|
|
||||||
totalLikes: 10,
|
|
||||||
totalViews: 100,
|
|
||||||
totalComments: 5,
|
|
||||||
wasLikedByMe: false,
|
|
||||||
);
|
|
||||||
final state = BlogPostState(isLoading: false, blogPostModel: blogPostModel, error: null);
|
final state = BlogPostState(isLoading: false, blogPostModel: blogPostModel, error: null);
|
||||||
|
|
||||||
when(blogPostCubit.state).thenReturn(state);
|
when(blogPostCubit.state).thenReturn(state);
|
||||||
@@ -248,11 +221,104 @@ void main() {
|
|||||||
await tester.pump();
|
await tester.pump();
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
await tester.tap(find.byIcon(Icons.favorite_border_rounded));
|
final likeIcon = find.byIcon(Icons.favorite_border_rounded);
|
||||||
|
await tester.ensureVisible(likeIcon);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(likeIcon);
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
|
|
||||||
verify(blogPostCubit.toggleLike()).called(1);
|
verify(blogPostCubit.toggleLike()).called(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets('renders gallery from fixture', (tester) async {
|
||||||
|
await mockNetworkImagesFor(() async {
|
||||||
|
final blogPostCubit = MockBlogPostCubit();
|
||||||
|
final feedCubit = MockFeedCubit();
|
||||||
|
final commentsCubit = MockCommentsCubit();
|
||||||
|
final blogPostModel = FeedFixtures.feedBlogPostWithGallery();
|
||||||
|
final state = BlogPostState(isLoading: false, blogPostModel: blogPostModel, error: null);
|
||||||
|
|
||||||
|
when(blogPostCubit.state).thenReturn(state);
|
||||||
|
when(blogPostCubit.stream).thenAnswer((_) => Stream.value(state));
|
||||||
|
when(blogPostCubit.close()).thenAnswer((_) async => Future<void>.value());
|
||||||
|
when(feedCubit.viewPost(any)).thenAnswer((_) async => Future<void>.value());
|
||||||
|
when(feedCubit.stream).thenAnswer((_) => const Stream.empty());
|
||||||
|
when(feedCubit.close()).thenAnswer((_) async => Future<void>.value());
|
||||||
|
when(commentsCubit.state).thenReturn(const CommentsState());
|
||||||
|
when(commentsCubit.stream).thenAnswer((_) => const Stream<CommentsState>.empty());
|
||||||
|
when(commentsCubit.close()).thenAnswer((_) async => Future<void>.value());
|
||||||
|
when(commentsCubit.getComments(any, any, any)).thenAnswer((_) async => Future<void>.value());
|
||||||
|
|
||||||
|
registerMocks(blogPostCubit, commentsCubit);
|
||||||
|
|
||||||
|
await tester.pumpWidget(wrap(blogPostCubit, feedCubit, commentsCubit, BlogPostScreen(id: blogPostModel.id)));
|
||||||
|
await tester.pump();
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.byType(BlogPostMediaContent), findsOneWidget);
|
||||||
|
expect(find.text('Галерея'), findsOneWidget);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('renders video-only post', (tester) async {
|
||||||
|
await mockNetworkImagesFor(() async {
|
||||||
|
final blogPostCubit = MockBlogPostCubit();
|
||||||
|
final feedCubit = MockFeedCubit();
|
||||||
|
final commentsCubit = MockCommentsCubit();
|
||||||
|
final blogPostModel = FeedFixtures.feedBlogPostVideoOnly();
|
||||||
|
final state = BlogPostState(isLoading: false, blogPostModel: blogPostModel, error: null);
|
||||||
|
|
||||||
|
when(blogPostCubit.state).thenReturn(state);
|
||||||
|
when(blogPostCubit.stream).thenAnswer((_) => Stream.value(state));
|
||||||
|
when(blogPostCubit.close()).thenAnswer((_) async => Future<void>.value());
|
||||||
|
when(feedCubit.viewPost(any)).thenAnswer((_) async => Future<void>.value());
|
||||||
|
when(feedCubit.stream).thenAnswer((_) => const Stream.empty());
|
||||||
|
when(feedCubit.close()).thenAnswer((_) async => Future<void>.value());
|
||||||
|
when(commentsCubit.state).thenReturn(const CommentsState());
|
||||||
|
when(commentsCubit.stream).thenAnswer((_) => const Stream<CommentsState>.empty());
|
||||||
|
when(commentsCubit.close()).thenAnswer((_) async => Future<void>.value());
|
||||||
|
when(commentsCubit.getComments(any, any, any)).thenAnswer((_) async => Future<void>.value());
|
||||||
|
|
||||||
|
registerMocks(blogPostCubit, commentsCubit);
|
||||||
|
|
||||||
|
await tester.pumpWidget(wrap(blogPostCubit, feedCubit, commentsCubit, BlogPostScreen(id: blogPostModel.id)));
|
||||||
|
await tester.pump();
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.byType(BlogPostMediaContent), findsOneWidget);
|
||||||
|
expect(find.text('Видео'), findsOneWidget);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('renders gallery and video post', (tester) async {
|
||||||
|
await mockNetworkImagesFor(() async {
|
||||||
|
final blogPostCubit = MockBlogPostCubit();
|
||||||
|
final feedCubit = MockFeedCubit();
|
||||||
|
final commentsCubit = MockCommentsCubit();
|
||||||
|
final blogPostModel = FeedFixtures.feedBlogPostGalleryAndVideo();
|
||||||
|
final state = BlogPostState(isLoading: false, blogPostModel: blogPostModel, error: null);
|
||||||
|
|
||||||
|
when(blogPostCubit.state).thenReturn(state);
|
||||||
|
when(blogPostCubit.stream).thenAnswer((_) => Stream.value(state));
|
||||||
|
when(blogPostCubit.close()).thenAnswer((_) async => Future<void>.value());
|
||||||
|
when(feedCubit.viewPost(any)).thenAnswer((_) async => Future<void>.value());
|
||||||
|
when(feedCubit.stream).thenAnswer((_) => const Stream.empty());
|
||||||
|
when(feedCubit.close()).thenAnswer((_) async => Future<void>.value());
|
||||||
|
when(commentsCubit.state).thenReturn(const CommentsState());
|
||||||
|
when(commentsCubit.stream).thenAnswer((_) => const Stream<CommentsState>.empty());
|
||||||
|
when(commentsCubit.close()).thenAnswer((_) async => Future<void>.value());
|
||||||
|
when(commentsCubit.getComments(any, any, any)).thenAnswer((_) async => Future<void>.value());
|
||||||
|
|
||||||
|
registerMocks(blogPostCubit, commentsCubit);
|
||||||
|
|
||||||
|
await tester.pumpWidget(wrap(blogPostCubit, feedCubit, commentsCubit, BlogPostScreen(id: blogPostModel.id)));
|
||||||
|
await tester.pump();
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.byType(BlogPostMediaContent), findsOneWidget);
|
||||||
|
expect(find.text('Галерея и видео'), findsWidgets);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,10 +21,18 @@ void main() {
|
|||||||
|
|
||||||
setUp(() {
|
setUp(() {
|
||||||
blogPostRepository = FixtureBlogPostRepository();
|
blogPostRepository = FixtureBlogPostRepository();
|
||||||
blogPostCreateCubit = BlogPostCreateCubit(blogPostRepository: blogPostRepository);
|
blogPostCreateCubit = BlogPostCreateCubit(
|
||||||
fullCreateFixture = FixtureReader.readAsMap('blog_post_create/create_request_full.json');
|
blogPostRepository: blogPostRepository,
|
||||||
updateFixture = FixtureReader.readAsMap('blog_post_create/update_request.json');
|
);
|
||||||
deleteFixture = FixtureReader.readAsMap('blog_post_create/delete_request.json');
|
fullCreateFixture = FixtureReader.readAsMap(
|
||||||
|
'blog_post_create/create_request_full.json',
|
||||||
|
);
|
||||||
|
updateFixture = FixtureReader.readAsMap(
|
||||||
|
'blog_post_create/update_request.json',
|
||||||
|
);
|
||||||
|
deleteFixture = FixtureReader.readAsMap(
|
||||||
|
'blog_post_create/delete_request.json',
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
tearDown(() {
|
tearDown(() {
|
||||||
@@ -41,7 +49,9 @@ void main() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
testWidgets('should handle blog post creation with all fields', (WidgetTester tester) async {
|
testWidgets('should handle blog post creation with all fields', (
|
||||||
|
WidgetTester tester,
|
||||||
|
) async {
|
||||||
// Arrange
|
// Arrange
|
||||||
Widget formWidget = Column(
|
Widget formWidget = Column(
|
||||||
children: [
|
children: [
|
||||||
@@ -69,7 +79,6 @@ void main() {
|
|||||||
title: fullCreateFixture['title'] as String?,
|
title: fullCreateFixture['title'] as String?,
|
||||||
description: fullCreateFixture['description_md'] as String?,
|
description: fullCreateFixture['description_md'] as String?,
|
||||||
videoUrl: fullCreateFixture['video_url'] as String?,
|
videoUrl: fullCreateFixture['video_url'] as String?,
|
||||||
image: null,
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
child: const Text('Опубликовать'),
|
child: const Text('Опубликовать'),
|
||||||
@@ -81,9 +90,18 @@ void main() {
|
|||||||
await tester.pumpWidget(createTestWidget(formWidget));
|
await tester.pumpWidget(createTestWidget(formWidget));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
await tester.enterText(find.byKey(AppKeys.blogPostCreateScreenTitleTextField), fullCreateFixture['title'] as String);
|
await tester.enterText(
|
||||||
await tester.enterText(find.byKey(AppKeys.blogPostCreateScreenContentTextField), fullCreateFixture['description_md'] as String);
|
find.byKey(AppKeys.blogPostCreateScreenTitleTextField),
|
||||||
await tester.enterText(find.byKey(AppKeys.blogPostCreateScreenVideoLinkTextField), fullCreateFixture['video_url'] as String);
|
fullCreateFixture['title'] as String,
|
||||||
|
);
|
||||||
|
await tester.enterText(
|
||||||
|
find.byKey(AppKeys.blogPostCreateScreenContentTextField),
|
||||||
|
fullCreateFixture['description_md'] as String,
|
||||||
|
);
|
||||||
|
await tester.enterText(
|
||||||
|
find.byKey(AppKeys.blogPostCreateScreenVideoLinkTextField),
|
||||||
|
fullCreateFixture['video_url'] as String,
|
||||||
|
);
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
|
|
||||||
await tester.tap(find.byKey(AppKeys.blogPostCreateScreenCreateButton));
|
await tester.tap(find.byKey(AppKeys.blogPostCreateScreenCreateButton));
|
||||||
@@ -96,7 +114,9 @@ void main() {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('should handle blog post creation with image', (WidgetTester tester) async {
|
testWidgets('should handle blog post creation with image', (
|
||||||
|
WidgetTester tester,
|
||||||
|
) async {
|
||||||
// Arrange
|
// Arrange
|
||||||
final expectedPayload = Map<String, dynamic>.from(fullCreateFixture);
|
final expectedPayload = Map<String, dynamic>.from(fullCreateFixture);
|
||||||
expectedPayload['video_url'] = null;
|
expectedPayload['video_url'] = null;
|
||||||
@@ -129,7 +149,11 @@ void main() {
|
|||||||
child: Column(
|
child: Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
Icon(Icons.add_photo_alternate, size: 48, color: Colors.grey),
|
Icon(
|
||||||
|
Icons.add_photo_alternate,
|
||||||
|
size: 48,
|
||||||
|
color: Colors.grey,
|
||||||
|
),
|
||||||
SizedBox(height: 8),
|
SizedBox(height: 8),
|
||||||
Text('Добавить изображение'),
|
Text('Добавить изображение'),
|
||||||
],
|
],
|
||||||
@@ -145,7 +169,6 @@ void main() {
|
|||||||
title: fullCreateFixture['title'] as String?,
|
title: fullCreateFixture['title'] as String?,
|
||||||
description: fullCreateFixture['description_md'] as String?,
|
description: fullCreateFixture['description_md'] as String?,
|
||||||
videoUrl: null,
|
videoUrl: null,
|
||||||
image: null, // В реальном тесте здесь был бы XFile
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
child: const Text('Опубликовать'),
|
child: const Text('Опубликовать'),
|
||||||
@@ -157,15 +180,24 @@ void main() {
|
|||||||
await tester.pumpWidget(createTestWidget(formWidget));
|
await tester.pumpWidget(createTestWidget(formWidget));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
await tester.enterText(find.byKey(AppKeys.blogPostCreateScreenTitleTextField), fullCreateFixture['title'] as String);
|
await tester.enterText(
|
||||||
await tester.enterText(find.byKey(AppKeys.blogPostCreateScreenContentTextField), fullCreateFixture['description_md'] as String);
|
find.byKey(AppKeys.blogPostCreateScreenTitleTextField),
|
||||||
|
fullCreateFixture['title'] as String,
|
||||||
|
);
|
||||||
|
await tester.enterText(
|
||||||
|
find.byKey(AppKeys.blogPostCreateScreenContentTextField),
|
||||||
|
fullCreateFixture['description_md'] as String,
|
||||||
|
);
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
|
|
||||||
await tester.tap(find.byKey(AppKeys.blogPostCreateScreenCreateButton));
|
await tester.tap(find.byKey(AppKeys.blogPostCreateScreenCreateButton));
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
expect(blogPostRepository.createPayloads.single.toJson(), expectedPayload);
|
expect(
|
||||||
|
blogPostRepository.createPayloads.single.toJson(),
|
||||||
|
expectedPayload,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('should handle blog post update', (WidgetTester tester) async {
|
testWidgets('should handle blog post update', (WidgetTester tester) async {
|
||||||
@@ -175,7 +207,12 @@ void main() {
|
|||||||
state: 'published',
|
state: 'published',
|
||||||
image: 'original_image.jpg',
|
image: 'original_image.jpg',
|
||||||
description: 'Original Description',
|
description: 'Original Description',
|
||||||
author: const Author(authorSlug: 'test-author', authorName: 'Test Author', authorProfileIcon: 'avatar.jpg', authorIsMe: true),
|
author: const Author(
|
||||||
|
authorSlug: 'test-author',
|
||||||
|
authorName: 'Test Author',
|
||||||
|
authorProfileIcon: 'avatar.jpg',
|
||||||
|
authorIsMe: true,
|
||||||
|
),
|
||||||
videoUrl: 'https://youtube.com/watch?v=original',
|
videoUrl: 'https://youtube.com/watch?v=original',
|
||||||
createdAt: DateTime.now(),
|
createdAt: DateTime.now(),
|
||||||
updatedAt: DateTime.now(),
|
updatedAt: DateTime.now(),
|
||||||
@@ -216,8 +253,9 @@ void main() {
|
|||||||
title: updateFixture['title'] as String?,
|
title: updateFixture['title'] as String?,
|
||||||
description: updateFixture['description_md'] as String?,
|
description: updateFixture['description_md'] as String?,
|
||||||
videoUrl: updateFixture['video_url'] as String?,
|
videoUrl: updateFixture['video_url'] as String?,
|
||||||
image: null,
|
existingMediaIds: List<int>.from(
|
||||||
oldImageUrl: updateFixture['old_image_url'] as String?,
|
updateFixture['existing_media_ids'] as List,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
child: const Text('Обновить'),
|
child: const Text('Обновить'),
|
||||||
@@ -229,9 +267,18 @@ void main() {
|
|||||||
await tester.pumpWidget(createTestWidget(formWidget));
|
await tester.pumpWidget(createTestWidget(formWidget));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
await tester.enterText(find.byKey(AppKeys.blogPostCreateScreenTitleTextField), updateFixture['title'] as String);
|
await tester.enterText(
|
||||||
await tester.enterText(find.byKey(AppKeys.blogPostCreateScreenContentTextField), updateFixture['description_md'] as String);
|
find.byKey(AppKeys.blogPostCreateScreenTitleTextField),
|
||||||
await tester.enterText(find.byKey(AppKeys.blogPostCreateScreenVideoLinkTextField), updateFixture['video_url'] as String);
|
updateFixture['title'] as String,
|
||||||
|
);
|
||||||
|
await tester.enterText(
|
||||||
|
find.byKey(AppKeys.blogPostCreateScreenContentTextField),
|
||||||
|
updateFixture['description_md'] as String,
|
||||||
|
);
|
||||||
|
await tester.enterText(
|
||||||
|
find.byKey(AppKeys.blogPostCreateScreenVideoLinkTextField),
|
||||||
|
updateFixture['video_url'] as String,
|
||||||
|
);
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
|
|
||||||
await tester.tap(find.byKey(AppKeys.blogPostCreateScreenCreateButton));
|
await tester.tap(find.byKey(AppKeys.blogPostCreateScreenCreateButton));
|
||||||
@@ -241,7 +288,9 @@ void main() {
|
|||||||
expect(blogPostRepository.updatePayloads.single.toJson(), updateFixture);
|
expect(blogPostRepository.updatePayloads.single.toJson(), updateFixture);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('should handle blog post deletion', (WidgetTester tester) async {
|
testWidgets('should handle blog post deletion', (
|
||||||
|
WidgetTester tester,
|
||||||
|
) async {
|
||||||
// Arrange
|
// Arrange
|
||||||
Widget deleteWidget = ElevatedButton(
|
Widget deleteWidget = ElevatedButton(
|
||||||
key: const Key('deleteBlogPostButton'),
|
key: const Key('deleteBlogPostButton'),
|
||||||
@@ -262,18 +311,21 @@ void main() {
|
|||||||
expect(blogPostRepository.deleteCalls.single, deleteFixture['id']);
|
expect(blogPostRepository.deleteCalls.single, deleteFixture['id']);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('should handle loading state during blog post creation', (WidgetTester tester) async {
|
testWidgets('should handle loading state during blog post creation', (
|
||||||
|
WidgetTester tester,
|
||||||
|
) async {
|
||||||
// Arrange
|
// Arrange
|
||||||
blogPostRepository.createDelay = const Duration(milliseconds: 50);
|
blogPostRepository.createDelay = const Duration(milliseconds: 50);
|
||||||
|
|
||||||
Widget loadingWidget = BlocBuilder<BlogPostCreateCubit, BlogPostCreateState>(
|
Widget loadingWidget =
|
||||||
builder: (context, state) {
|
BlocBuilder<BlogPostCreateCubit, BlogPostCreateState>(
|
||||||
if (state is BlogPostCreateStateLoading) {
|
builder: (context, state) {
|
||||||
return const Center(child: CircularProgressIndicator());
|
if (state.isLoading) {
|
||||||
}
|
return const Center(child: CircularProgressIndicator());
|
||||||
return const Text('Not Loading');
|
}
|
||||||
},
|
return const Text('Not Loading');
|
||||||
);
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
await tester.pumpWidget(createTestWidget(loadingWidget));
|
await tester.pumpWidget(createTestWidget(loadingWidget));
|
||||||
@@ -284,7 +336,6 @@ void main() {
|
|||||||
title: fullCreateFixture['title'] as String?,
|
title: fullCreateFixture['title'] as String?,
|
||||||
videoUrl: null,
|
videoUrl: null,
|
||||||
description: fullCreateFixture['description_md'] as String?,
|
description: fullCreateFixture['description_md'] as String?,
|
||||||
image: null,
|
|
||||||
);
|
);
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
await tester.pump(); // Need to pump twice to get the loading state
|
await tester.pump(); // Need to pump twice to get the loading state
|
||||||
@@ -297,18 +348,21 @@ void main() {
|
|||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('should handle error state during blog post creation', (WidgetTester tester) async {
|
testWidgets('should handle error state during blog post creation', (
|
||||||
|
WidgetTester tester,
|
||||||
|
) async {
|
||||||
// Arrange
|
// Arrange
|
||||||
blogPostRepository.createError = Exception('Network error');
|
blogPostRepository.createError = Exception('Network error');
|
||||||
|
|
||||||
Widget errorWidget = BlocBuilder<BlogPostCreateCubit, BlogPostCreateState>(
|
Widget errorWidget =
|
||||||
builder: (context, state) {
|
BlocBuilder<BlogPostCreateCubit, BlogPostCreateState>(
|
||||||
if (state is BlogPostCreateStateError) {
|
builder: (context, state) {
|
||||||
return Center(child: Text('Error: ${state.errorMessage}'));
|
if (state.errorMessage != null) {
|
||||||
}
|
return Center(child: Text('Error: ${state.errorMessage}'));
|
||||||
return const Text('No Error');
|
}
|
||||||
},
|
return const Text('No Error');
|
||||||
);
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
await tester.pumpWidget(createTestWidget(errorWidget));
|
await tester.pumpWidget(createTestWidget(errorWidget));
|
||||||
@@ -319,7 +373,6 @@ void main() {
|
|||||||
title: fullCreateFixture['title'] as String?,
|
title: fullCreateFixture['title'] as String?,
|
||||||
videoUrl: null,
|
videoUrl: null,
|
||||||
description: fullCreateFixture['description_md'] as String?,
|
description: fullCreateFixture['description_md'] as String?,
|
||||||
image: null,
|
|
||||||
);
|
);
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
await tester.pump(); // Need to pump twice to get the error state
|
await tester.pump(); // Need to pump twice to get the error state
|
||||||
@@ -328,16 +381,21 @@ void main() {
|
|||||||
expect(find.textContaining('Error:'), findsOneWidget);
|
expect(find.textContaining('Error:'), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('should handle success state after blog post creation', (WidgetTester tester) async {
|
testWidgets('should handle success state after blog post creation', (
|
||||||
|
WidgetTester tester,
|
||||||
|
) async {
|
||||||
// Arrange
|
// Arrange
|
||||||
Widget successWidget = BlocBuilder<BlogPostCreateCubit, BlogPostCreateState>(
|
Widget successWidget =
|
||||||
builder: (context, state) {
|
BlocBuilder<BlogPostCreateCubit, BlogPostCreateState>(
|
||||||
if (state is BlogPostCreateStateLoaded) {
|
builder: (context, state) {
|
||||||
return const Center(child: Text('Blog post created successfully!'));
|
if (state.isSuccess) {
|
||||||
}
|
return const Center(
|
||||||
return const Text('Not Loaded');
|
child: Text('Blog post created successfully!'),
|
||||||
},
|
);
|
||||||
);
|
}
|
||||||
|
return const Text('Not Loaded');
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
await tester.pumpWidget(createTestWidget(successWidget));
|
await tester.pumpWidget(createTestWidget(successWidget));
|
||||||
@@ -348,7 +406,6 @@ void main() {
|
|||||||
title: fullCreateFixture['title'] as String?,
|
title: fullCreateFixture['title'] as String?,
|
||||||
videoUrl: null,
|
videoUrl: null,
|
||||||
description: fullCreateFixture['description_md'] as String?,
|
description: fullCreateFixture['description_md'] as String?,
|
||||||
image: null,
|
|
||||||
);
|
);
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
await tester.pump(); // Need to pump twice to get the success state
|
await tester.pump(); // Need to pump twice to get the success state
|
||||||
@@ -357,23 +414,35 @@ void main() {
|
|||||||
expect(find.text('Blog post created successfully!'), findsOneWidget);
|
expect(find.text('Blog post created successfully!'), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('should validate form fields before submission', (WidgetTester tester) async {
|
testWidgets('should validate form fields before submission', (
|
||||||
|
WidgetTester tester,
|
||||||
|
) async {
|
||||||
// Arrange
|
// Arrange
|
||||||
Widget validationWidget = Column(
|
Widget validationWidget = Column(
|
||||||
children: [
|
children: [
|
||||||
TextFormField(
|
TextFormField(
|
||||||
key: AppKeys.blogPostCreateScreenTitleTextField,
|
key: AppKeys.blogPostCreateScreenTitleTextField,
|
||||||
decoration: const InputDecoration(labelText: 'Заголовок', errorText: 'Заголовок не может быть пустым'),
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Заголовок',
|
||||||
|
errorText: 'Заголовок не может быть пустым',
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
TextFormField(
|
TextFormField(
|
||||||
key: AppKeys.blogPostCreateScreenContentTextField,
|
key: AppKeys.blogPostCreateScreenContentTextField,
|
||||||
decoration: const InputDecoration(labelText: 'Описание', errorText: 'Описание не может быть пустым'),
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Описание',
|
||||||
|
errorText: 'Описание не может быть пустым',
|
||||||
|
),
|
||||||
maxLines: 6,
|
maxLines: 6,
|
||||||
minLines: 4,
|
minLines: 4,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
ElevatedButton(key: AppKeys.blogPostCreateScreenCreateButton, onPressed: () {}, child: const Text('Опубликовать')),
|
ElevatedButton(
|
||||||
|
key: AppKeys.blogPostCreateScreenCreateButton,
|
||||||
|
onPressed: () {},
|
||||||
|
child: const Text('Опубликовать'),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -386,7 +455,9 @@ void main() {
|
|||||||
expect(find.text('Описание не может быть пустым'), findsOneWidget);
|
expect(find.text('Описание не может быть пустым'), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('should handle image picker interaction', (WidgetTester tester) async {
|
testWidgets('should handle image picker interaction', (
|
||||||
|
WidgetTester tester,
|
||||||
|
) async {
|
||||||
// Arrange
|
// Arrange
|
||||||
bool imagePickerCalled = false;
|
bool imagePickerCalled = false;
|
||||||
Widget imagePickerWidget = GestureDetector(
|
Widget imagePickerWidget = GestureDetector(
|
||||||
@@ -425,16 +496,25 @@ void main() {
|
|||||||
expect(imagePickerCalled, isTrue);
|
expect(imagePickerCalled, isTrue);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('should handle video URL validation', (WidgetTester tester) async {
|
testWidgets('should handle video URL validation', (
|
||||||
|
WidgetTester tester,
|
||||||
|
) async {
|
||||||
// Arrange
|
// Arrange
|
||||||
Widget videoValidationWidget = Column(
|
Widget videoValidationWidget = Column(
|
||||||
children: [
|
children: [
|
||||||
TextFormField(
|
TextFormField(
|
||||||
key: AppKeys.blogPostCreateScreenVideoLinkTextField,
|
key: AppKeys.blogPostCreateScreenVideoLinkTextField,
|
||||||
decoration: const InputDecoration(labelText: 'Ссылка на видео', errorText: 'Введите корректную ссылку на видео'),
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Ссылка на видео',
|
||||||
|
errorText: 'Введите корректную ссылку на видео',
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
ElevatedButton(key: AppKeys.blogPostCreateScreenCreateButton, onPressed: () {}, child: const Text('Опубликовать')),
|
ElevatedButton(
|
||||||
|
key: AppKeys.blogPostCreateScreenCreateButton,
|
||||||
|
onPressed: () {},
|
||||||
|
child: const Text('Опубликовать'),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -446,7 +526,9 @@ void main() {
|
|||||||
expect(find.text('Введите корректную ссылку на видео'), findsOneWidget);
|
expect(find.text('Введите корректную ссылку на видео'), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('should handle form submission with empty fields', (WidgetTester tester) async {
|
testWidgets('should handle form submission with empty fields', (
|
||||||
|
WidgetTester tester,
|
||||||
|
) async {
|
||||||
// Arrange
|
// Arrange
|
||||||
Widget emptyFormWidget = Column(
|
Widget emptyFormWidget = Column(
|
||||||
children: [
|
children: [
|
||||||
@@ -465,7 +547,11 @@ void main() {
|
|||||||
ElevatedButton(
|
ElevatedButton(
|
||||||
key: AppKeys.blogPostCreateScreenCreateButton,
|
key: AppKeys.blogPostCreateScreenCreateButton,
|
||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
await blogPostCreateCubit.createBlogPost(title: '', videoUrl: '', description: '', image: null);
|
await blogPostCreateCubit.createBlogPost(
|
||||||
|
title: '',
|
||||||
|
videoUrl: '',
|
||||||
|
description: '',
|
||||||
|
);
|
||||||
},
|
},
|
||||||
child: const Text('Опубликовать'),
|
child: const Text('Опубликовать'),
|
||||||
),
|
),
|
||||||
@@ -479,14 +565,14 @@ void main() {
|
|||||||
await tester.tap(find.byKey(AppKeys.blogPostCreateScreenCreateButton));
|
await tester.tap(find.byKey(AppKeys.blogPostCreateScreenCreateButton));
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
|
|
||||||
// Assert
|
// Assert — invalid empty payload must not reach the repository
|
||||||
expect(
|
expect(blogPostRepository.createPayloads, isEmpty);
|
||||||
blogPostRepository.createPayloads.single.toJson(),
|
expect(blogPostCreateCubit.state.hasValidationErrors, isTrue);
|
||||||
{'title': '', 'description_md': '', 'video_url': '', 'image_name': null},
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('should handle blog post creation with long content', (WidgetTester tester) async {
|
testWidgets('should handle blog post creation with long content', (
|
||||||
|
WidgetTester tester,
|
||||||
|
) async {
|
||||||
// Arrange
|
// Arrange
|
||||||
const longContent =
|
const longContent =
|
||||||
'Это очень длинное описание блог поста, которое содержит много текста и должно быть правильно обработано системой. '
|
'Это очень длинное описание блог поста, которое содержит много текста и должно быть правильно обработано системой. '
|
||||||
@@ -510,7 +596,11 @@ void main() {
|
|||||||
ElevatedButton(
|
ElevatedButton(
|
||||||
key: AppKeys.blogPostCreateScreenCreateButton,
|
key: AppKeys.blogPostCreateScreenCreateButton,
|
||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
await blogPostCreateCubit.createBlogPost(title: 'Test Title', videoUrl: null, description: longContent, image: null);
|
await blogPostCreateCubit.createBlogPost(
|
||||||
|
title: 'Test Title',
|
||||||
|
videoUrl: null,
|
||||||
|
description: longContent,
|
||||||
|
);
|
||||||
},
|
},
|
||||||
child: const Text('Опубликовать'),
|
child: const Text('Опубликовать'),
|
||||||
),
|
),
|
||||||
@@ -521,23 +611,26 @@ void main() {
|
|||||||
await tester.pumpWidget(createTestWidget(longContentWidget));
|
await tester.pumpWidget(createTestWidget(longContentWidget));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
await tester.enterText(find.byKey(AppKeys.blogPostCreateScreenTitleTextField), 'Test Title');
|
await tester.enterText(
|
||||||
await tester.enterText(find.byKey(AppKeys.blogPostCreateScreenContentTextField), longContent);
|
find.byKey(AppKeys.blogPostCreateScreenTitleTextField),
|
||||||
|
'Test Title',
|
||||||
|
);
|
||||||
|
await tester.enterText(
|
||||||
|
find.byKey(AppKeys.blogPostCreateScreenContentTextField),
|
||||||
|
longContent,
|
||||||
|
);
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
|
|
||||||
await tester.tap(find.byKey(AppKeys.blogPostCreateScreenCreateButton));
|
await tester.tap(find.byKey(AppKeys.blogPostCreateScreenCreateButton));
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
expect(
|
expect(blogPostRepository.createPayloads.single.toJson(), {
|
||||||
blogPostRepository.createPayloads.single.toJson(),
|
'title': 'Test Title',
|
||||||
{
|
'description_md': longContent,
|
||||||
'title': 'Test Title',
|
'video_url': null,
|
||||||
'description_md': longContent,
|
'image_name': null,
|
||||||
'video_url': null,
|
});
|
||||||
'image_name': null,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,9 @@ void main() {
|
|||||||
|
|
||||||
setUp(() {
|
setUp(() {
|
||||||
blogPostRepository = FixtureBlogPostRepository();
|
blogPostRepository = FixtureBlogPostRepository();
|
||||||
blogPostCreateCubit = BlogPostCreateCubit(blogPostRepository: blogPostRepository);
|
blogPostCreateCubit = BlogPostCreateCubit(
|
||||||
|
blogPostRepository: blogPostRepository,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
tearDown(() {
|
tearDown(() {
|
||||||
@@ -31,7 +33,9 @@ void main() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
testWidgets('should create BlogPostCreateCubit successfully', (WidgetTester tester) async {
|
testWidgets('should create BlogPostCreateCubit successfully', (
|
||||||
|
WidgetTester tester,
|
||||||
|
) async {
|
||||||
// Arrange & Act
|
// Arrange & Act
|
||||||
final cubit = BlogPostCreateCubit(blogPostRepository: blogPostRepository);
|
final cubit = BlogPostCreateCubit(blogPostRepository: blogPostRepository);
|
||||||
|
|
||||||
@@ -41,13 +45,18 @@ void main() {
|
|||||||
cubit.close();
|
cubit.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('should display basic blog post form fields', (WidgetTester tester) async {
|
testWidgets('should display basic blog post form fields', (
|
||||||
|
WidgetTester tester,
|
||||||
|
) async {
|
||||||
// Arrange
|
// Arrange
|
||||||
Widget formWidget = Column(
|
Widget formWidget = Column(
|
||||||
children: [
|
children: [
|
||||||
TextFormField(
|
TextFormField(
|
||||||
key: AppKeys.blogPostCreateScreenTitleTextField,
|
key: AppKeys.blogPostCreateScreenTitleTextField,
|
||||||
decoration: const InputDecoration(labelText: 'Заголовок', hintText: 'Введите заголовок поста'),
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Заголовок',
|
||||||
|
hintText: 'Введите заголовок поста',
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
TextFormField(
|
TextFormField(
|
||||||
@@ -62,7 +71,11 @@ void main() {
|
|||||||
decoration: const InputDecoration(labelText: 'Ссылка на видео'),
|
decoration: const InputDecoration(labelText: 'Ссылка на видео'),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
ElevatedButton(key: AppKeys.blogPostCreateScreenCreateButton, onPressed: () {}, child: const Text('Опубликовать')),
|
ElevatedButton(
|
||||||
|
key: AppKeys.blogPostCreateScreenCreateButton,
|
||||||
|
onPressed: () {},
|
||||||
|
child: const Text('Опубликовать'),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -71,17 +84,34 @@ void main() {
|
|||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
expect(find.byKey(AppKeys.blogPostCreateScreenTitleTextField), findsOneWidget);
|
expect(
|
||||||
expect(find.byKey(AppKeys.blogPostCreateScreenContentTextField), findsOneWidget);
|
find.byKey(AppKeys.blogPostCreateScreenTitleTextField),
|
||||||
expect(find.byKey(AppKeys.blogPostCreateScreenVideoLinkTextField), findsOneWidget);
|
findsOneWidget,
|
||||||
expect(find.byKey(AppKeys.blogPostCreateScreenCreateButton), findsOneWidget);
|
);
|
||||||
|
expect(
|
||||||
|
find.byKey(AppKeys.blogPostCreateScreenContentTextField),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
find.byKey(AppKeys.blogPostCreateScreenVideoLinkTextField),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
find.byKey(AppKeys.blogPostCreateScreenCreateButton),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('should update title when user types in title field', (WidgetTester tester) async {
|
testWidgets('should update title when user types in title field', (
|
||||||
|
WidgetTester tester,
|
||||||
|
) async {
|
||||||
// Arrange
|
// Arrange
|
||||||
Widget formWidget = TextFormField(
|
Widget formWidget = TextFormField(
|
||||||
key: AppKeys.blogPostCreateScreenTitleTextField,
|
key: AppKeys.blogPostCreateScreenTitleTextField,
|
||||||
decoration: const InputDecoration(labelText: 'Заголовок', hintText: 'Введите заголовок поста'),
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Заголовок',
|
||||||
|
hintText: 'Введите заголовок поста',
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
await tester.pumpWidget(createTestWidget(formWidget));
|
await tester.pumpWidget(createTestWidget(formWidget));
|
||||||
@@ -90,36 +120,47 @@ void main() {
|
|||||||
const testTitle = 'Тестовый заголовок поста';
|
const testTitle = 'Тестовый заголовок поста';
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
await tester.enterText(find.byKey(AppKeys.blogPostCreateScreenTitleTextField), testTitle);
|
await tester.enterText(
|
||||||
|
find.byKey(AppKeys.blogPostCreateScreenTitleTextField),
|
||||||
|
testTitle,
|
||||||
|
);
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
expect(find.text(testTitle), findsOneWidget);
|
expect(find.text(testTitle), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('should update description when user types in description field', (WidgetTester tester) async {
|
testWidgets(
|
||||||
// Arrange
|
'should update description when user types in description field',
|
||||||
Widget formWidget = TextFormField(
|
(WidgetTester tester) async {
|
||||||
key: AppKeys.blogPostCreateScreenContentTextField,
|
// Arrange
|
||||||
decoration: const InputDecoration(labelText: 'Описание'),
|
Widget formWidget = TextFormField(
|
||||||
maxLines: 6,
|
key: AppKeys.blogPostCreateScreenContentTextField,
|
||||||
minLines: 4,
|
decoration: const InputDecoration(labelText: 'Описание'),
|
||||||
);
|
maxLines: 6,
|
||||||
|
minLines: 4,
|
||||||
|
);
|
||||||
|
|
||||||
await tester.pumpWidget(createTestWidget(formWidget));
|
await tester.pumpWidget(createTestWidget(formWidget));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
const testDescription = 'Это тестовое описание блог поста';
|
const testDescription = 'Это тестовое описание блог поста';
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
await tester.enterText(find.byKey(AppKeys.blogPostCreateScreenContentTextField), testDescription);
|
await tester.enterText(
|
||||||
await tester.pump();
|
find.byKey(AppKeys.blogPostCreateScreenContentTextField),
|
||||||
|
testDescription,
|
||||||
|
);
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
expect(find.text(testDescription), findsOneWidget);
|
expect(find.text(testDescription), findsOneWidget);
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
testWidgets('should update video URL when user types in video field', (WidgetTester tester) async {
|
testWidgets('should update video URL when user types in video field', (
|
||||||
|
WidgetTester tester,
|
||||||
|
) async {
|
||||||
// Arrange
|
// Arrange
|
||||||
Widget formWidget = TextFormField(
|
Widget formWidget = TextFormField(
|
||||||
key: AppKeys.blogPostCreateScreenVideoLinkTextField,
|
key: AppKeys.blogPostCreateScreenVideoLinkTextField,
|
||||||
@@ -132,16 +173,27 @@ void main() {
|
|||||||
const testVideoUrl = 'https://youtube.com/watch?v=example';
|
const testVideoUrl = 'https://youtube.com/watch?v=example';
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
await tester.enterText(find.byKey(AppKeys.blogPostCreateScreenVideoLinkTextField), testVideoUrl);
|
await tester.enterText(
|
||||||
|
find.byKey(AppKeys.blogPostCreateScreenVideoLinkTextField),
|
||||||
|
testVideoUrl,
|
||||||
|
);
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
expect(find.text(testVideoUrl), findsOneWidget);
|
expect(find.text(testVideoUrl), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('should display form labels correctly', (WidgetTester tester) async {
|
testWidgets('should display form labels correctly', (
|
||||||
|
WidgetTester tester,
|
||||||
|
) async {
|
||||||
// Arrange
|
// Arrange
|
||||||
Widget formWidget = const Column(children: [Text('Заголовок'), Text('Описание'), Text('Ссылка на видео')]);
|
Widget formWidget = const Column(
|
||||||
|
children: [
|
||||||
|
Text('Заголовок'),
|
||||||
|
Text('Описание'),
|
||||||
|
Text('Ссылка на видео'),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
await tester.pumpWidget(createTestWidget(formWidget));
|
await tester.pumpWidget(createTestWidget(formWidget));
|
||||||
@@ -153,11 +205,16 @@ void main() {
|
|||||||
expect(find.text('Ссылка на видео'), findsOneWidget);
|
expect(find.text('Ссылка на видео'), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('should display placeholder text in title field', (WidgetTester tester) async {
|
testWidgets('should display placeholder text in title field', (
|
||||||
|
WidgetTester tester,
|
||||||
|
) async {
|
||||||
// Arrange
|
// Arrange
|
||||||
Widget formWidget = TextFormField(
|
Widget formWidget = TextFormField(
|
||||||
key: AppKeys.blogPostCreateScreenTitleTextField,
|
key: AppKeys.blogPostCreateScreenTitleTextField,
|
||||||
decoration: const InputDecoration(labelText: 'Заголовок', hintText: 'Введите заголовок поста'),
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Заголовок',
|
||||||
|
hintText: 'Введите заголовок поста',
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
@@ -170,14 +227,21 @@ void main() {
|
|||||||
|
|
||||||
testWidgets('should display create button', (WidgetTester tester) async {
|
testWidgets('should display create button', (WidgetTester tester) async {
|
||||||
// Arrange
|
// Arrange
|
||||||
Widget formWidget = ElevatedButton(key: AppKeys.blogPostCreateScreenCreateButton, onPressed: () {}, child: const Text('Опубликовать'));
|
Widget formWidget = ElevatedButton(
|
||||||
|
key: AppKeys.blogPostCreateScreenCreateButton,
|
||||||
|
onPressed: () {},
|
||||||
|
child: const Text('Опубликовать'),
|
||||||
|
);
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
await tester.pumpWidget(createTestWidget(formWidget));
|
await tester.pumpWidget(createTestWidget(formWidget));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
expect(find.byKey(AppKeys.blogPostCreateScreenCreateButton), findsOneWidget);
|
expect(
|
||||||
|
find.byKey(AppKeys.blogPostCreateScreenCreateButton),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
expect(find.text('Опубликовать'), findsOneWidget);
|
expect(find.text('Опубликовать'), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -217,19 +281,26 @@ void main() {
|
|||||||
|
|
||||||
testWidgets('should display error message', (WidgetTester tester) async {
|
testWidgets('should display error message', (WidgetTester tester) async {
|
||||||
// Arrange
|
// Arrange
|
||||||
Widget errorWidget = const Center(child: Text('Произошла ошибка при создании блог поста'));
|
Widget errorWidget = const Center(
|
||||||
|
child: Text('Произошла ошибка при создании блог поста'),
|
||||||
|
);
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
await tester.pumpWidget(createTestWidget(errorWidget));
|
await tester.pumpWidget(createTestWidget(errorWidget));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
expect(find.text('Произошла ошибка при создании блог поста'), findsOneWidget);
|
expect(
|
||||||
|
find.text('Произошла ошибка при создании блог поста'),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('should display success message', (WidgetTester tester) async {
|
testWidgets('should display success message', (WidgetTester tester) async {
|
||||||
// Arrange
|
// Arrange
|
||||||
Widget successWidget = const Center(child: Text('Блог пост успешно создан!'));
|
Widget successWidget = const Center(
|
||||||
|
child: Text('Блог пост успешно создан!'),
|
||||||
|
);
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
await tester.pumpWidget(createTestWidget(successWidget));
|
await tester.pumpWidget(createTestWidget(successWidget));
|
||||||
@@ -245,12 +316,18 @@ void main() {
|
|||||||
children: [
|
children: [
|
||||||
TextFormField(
|
TextFormField(
|
||||||
key: AppKeys.blogPostCreateScreenTitleTextField,
|
key: AppKeys.blogPostCreateScreenTitleTextField,
|
||||||
decoration: const InputDecoration(labelText: 'Заголовок', errorText: 'Заголовок обязателен'),
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Заголовок',
|
||||||
|
errorText: 'Заголовок обязателен',
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
TextFormField(
|
TextFormField(
|
||||||
key: AppKeys.blogPostCreateScreenContentTextField,
|
key: AppKeys.blogPostCreateScreenContentTextField,
|
||||||
decoration: const InputDecoration(labelText: 'Описание', errorText: 'Описание обязательно'),
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Описание',
|
||||||
|
errorText: 'Описание обязательно',
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@@ -264,13 +341,18 @@ void main() {
|
|||||||
expect(find.text('Описание обязательно'), findsOneWidget);
|
expect(find.text('Описание обязательно'), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('should display form with all required fields', (WidgetTester tester) async {
|
testWidgets('should display form with all required fields', (
|
||||||
|
WidgetTester tester,
|
||||||
|
) async {
|
||||||
// Arrange
|
// Arrange
|
||||||
Widget completeForm = Column(
|
Widget completeForm = Column(
|
||||||
children: [
|
children: [
|
||||||
TextFormField(
|
TextFormField(
|
||||||
key: AppKeys.blogPostCreateScreenTitleTextField,
|
key: AppKeys.blogPostCreateScreenTitleTextField,
|
||||||
decoration: const InputDecoration(labelText: 'Заголовок', hintText: 'Введите заголовок поста'),
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Заголовок',
|
||||||
|
hintText: 'Введите заголовок поста',
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
TextFormField(
|
TextFormField(
|
||||||
@@ -285,7 +367,11 @@ void main() {
|
|||||||
decoration: const InputDecoration(labelText: 'Ссылка на видео'),
|
decoration: const InputDecoration(labelText: 'Ссылка на видео'),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 32),
|
const SizedBox(height: 32),
|
||||||
ElevatedButton(key: AppKeys.blogPostCreateScreenCreateButton, onPressed: () {}, child: const Text('Опубликовать')),
|
ElevatedButton(
|
||||||
|
key: AppKeys.blogPostCreateScreenCreateButton,
|
||||||
|
onPressed: () {},
|
||||||
|
child: const Text('Опубликовать'),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -294,13 +380,27 @@ void main() {
|
|||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
expect(find.byKey(AppKeys.blogPostCreateScreenTitleTextField), findsOneWidget);
|
expect(
|
||||||
expect(find.byKey(AppKeys.blogPostCreateScreenContentTextField), findsOneWidget);
|
find.byKey(AppKeys.blogPostCreateScreenTitleTextField),
|
||||||
expect(find.byKey(AppKeys.blogPostCreateScreenVideoLinkTextField), findsOneWidget);
|
findsOneWidget,
|
||||||
expect(find.byKey(AppKeys.blogPostCreateScreenCreateButton), findsOneWidget);
|
);
|
||||||
|
expect(
|
||||||
|
find.byKey(AppKeys.blogPostCreateScreenContentTextField),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
find.byKey(AppKeys.blogPostCreateScreenVideoLinkTextField),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
find.byKey(AppKeys.blogPostCreateScreenCreateButton),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('should display image upload widget', (WidgetTester tester) async {
|
testWidgets('should display image upload widget', (
|
||||||
|
WidgetTester tester,
|
||||||
|
) async {
|
||||||
// Arrange
|
// Arrange
|
||||||
Widget imageWidget = GestureDetector(
|
Widget imageWidget = GestureDetector(
|
||||||
key: AppKeys.imageWidget,
|
key: AppKeys.imageWidget,
|
||||||
@@ -373,68 +473,101 @@ void main() {
|
|||||||
expect(imageUploadTapped, isTrue);
|
expect(imageUploadTapped, isTrue);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('should display blog post creation form with image and video options', (WidgetTester tester) async {
|
testWidgets(
|
||||||
// Arrange
|
'should display blog post creation form with image and video options',
|
||||||
Widget completeForm = Column(
|
(WidgetTester tester) async {
|
||||||
children: [
|
// Arrange
|
||||||
const Text('Заголовок'),
|
Widget completeForm = Column(
|
||||||
const SizedBox(height: 8),
|
children: [
|
||||||
TextFormField(
|
const Text('Заголовок'),
|
||||||
key: AppKeys.blogPostCreateScreenTitleTextField,
|
const SizedBox(height: 8),
|
||||||
decoration: const InputDecoration(hintText: 'Введите заголовок поста'),
|
TextFormField(
|
||||||
),
|
key: AppKeys.blogPostCreateScreenTitleTextField,
|
||||||
const SizedBox(height: 16),
|
decoration: const InputDecoration(
|
||||||
const Text('Описание'),
|
hintText: 'Введите заголовок поста',
|
||||||
const SizedBox(height: 8),
|
|
||||||
TextFormField(key: AppKeys.blogPostCreateScreenContentTextField, decoration: const InputDecoration(), maxLines: 6, minLines: 4),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
const Text('Изображение поста'),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
GestureDetector(
|
|
||||||
key: AppKeys.imageWidget,
|
|
||||||
onTap: () {},
|
|
||||||
child: Container(
|
|
||||||
height: 200,
|
|
||||||
width: double.infinity,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
border: Border.all(color: Colors.grey),
|
|
||||||
borderRadius: BorderRadius.circular(8),
|
|
||||||
),
|
),
|
||||||
child: const Center(
|
),
|
||||||
child: Column(
|
const SizedBox(height: 16),
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
const Text('Описание'),
|
||||||
children: [
|
const SizedBox(height: 8),
|
||||||
Icon(Icons.add_photo_alternate, size: 48, color: Colors.grey),
|
TextFormField(
|
||||||
SizedBox(height: 8),
|
key: AppKeys.blogPostCreateScreenContentTextField,
|
||||||
Text('Добавить изображение'),
|
decoration: const InputDecoration(),
|
||||||
],
|
maxLines: 6,
|
||||||
|
minLines: 4,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
const Text('Изображение поста'),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
GestureDetector(
|
||||||
|
key: AppKeys.imageWidget,
|
||||||
|
onTap: () {},
|
||||||
|
child: Container(
|
||||||
|
height: 200,
|
||||||
|
width: double.infinity,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
border: Border.all(color: Colors.grey),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: const Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.add_photo_alternate,
|
||||||
|
size: 48,
|
||||||
|
color: Colors.grey,
|
||||||
|
),
|
||||||
|
SizedBox(height: 8),
|
||||||
|
Text('Добавить изображение'),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
const SizedBox(height: 16),
|
||||||
const SizedBox(height: 16),
|
const Text('Ссылка на видео'),
|
||||||
const Text('Ссылка на видео'),
|
const SizedBox(height: 8),
|
||||||
const SizedBox(height: 8),
|
TextFormField(
|
||||||
TextFormField(key: AppKeys.blogPostCreateScreenVideoLinkTextField, decoration: const InputDecoration()),
|
key: AppKeys.blogPostCreateScreenVideoLinkTextField,
|
||||||
const SizedBox(height: 32),
|
decoration: const InputDecoration(),
|
||||||
ElevatedButton(key: AppKeys.blogPostCreateScreenCreateButton, onPressed: () {}, child: const Text('Опубликовать')),
|
),
|
||||||
],
|
const SizedBox(height: 32),
|
||||||
);
|
ElevatedButton(
|
||||||
|
key: AppKeys.blogPostCreateScreenCreateButton,
|
||||||
|
onPressed: () {},
|
||||||
|
child: const Text('Опубликовать'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
await tester.pumpWidget(createTestWidget(completeForm));
|
await tester.pumpWidget(createTestWidget(completeForm));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
expect(find.text('Заголовок'), findsOneWidget);
|
expect(find.text('Заголовок'), findsOneWidget);
|
||||||
expect(find.text('Описание'), findsOneWidget);
|
expect(find.text('Описание'), findsOneWidget);
|
||||||
expect(find.text('Изображение поста'), findsOneWidget);
|
expect(find.text('Изображение поста'), findsOneWidget);
|
||||||
expect(find.text('Ссылка на видео'), findsOneWidget);
|
expect(find.text('Ссылка на видео'), findsOneWidget);
|
||||||
expect(find.byKey(AppKeys.blogPostCreateScreenTitleTextField), findsOneWidget);
|
expect(
|
||||||
expect(find.byKey(AppKeys.blogPostCreateScreenContentTextField), findsOneWidget);
|
find.byKey(AppKeys.blogPostCreateScreenTitleTextField),
|
||||||
expect(find.byKey(AppKeys.imageWidget), findsOneWidget);
|
findsOneWidget,
|
||||||
expect(find.byKey(AppKeys.blogPostCreateScreenVideoLinkTextField), findsOneWidget);
|
);
|
||||||
expect(find.byKey(AppKeys.blogPostCreateScreenCreateButton), findsOneWidget);
|
expect(
|
||||||
});
|
find.byKey(AppKeys.blogPostCreateScreenContentTextField),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
|
expect(find.byKey(AppKeys.imageWidget), findsOneWidget);
|
||||||
|
expect(
|
||||||
|
find.byKey(AppKeys.blogPostCreateScreenVideoLinkTextField),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
find.byKey(AppKeys.blogPostCreateScreenCreateButton),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import 'package:eatvio_app/features/blog_post_create/bloc/blog_post_create_cubit/blog_post_create_cubit.dart';
|
import 'package:eatvio_app/features/blog_post_create/bloc/blog_post_create_cubit/blog_post_create_cubit.dart';
|
||||||
import 'package:eatvio_app/features/blog_post_create/bloc/blog_post_create_cubit/blog_post_create_state.dart';
|
import 'package:eatvio_app/features/blog_post_create/bloc/blog_post_create_cubit/blog_post_create_state.dart';
|
||||||
import 'package:eatvio_app/features/blog_post_create/repositories/blog_post_repository.dart';
|
import 'package:eatvio_app/features/blog_post_create/repositories/blog_post_repository.dart';
|
||||||
|
import 'package:eatvio_app/features/blog_post_create/validation/blog_post_form_validator.dart';
|
||||||
|
import 'package:flutter_image_compress/flutter_image_compress.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:mocktail/mocktail.dart';
|
import 'package:mocktail/mocktail.dart';
|
||||||
|
|
||||||
@@ -14,6 +16,8 @@ void main() {
|
|||||||
setUp(() {
|
setUp(() {
|
||||||
mockRepository = MockBlogPostRepository();
|
mockRepository = MockBlogPostRepository();
|
||||||
cubit = BlogPostCreateCubit(blogPostRepository: mockRepository);
|
cubit = BlogPostCreateCubit(blogPostRepository: mockRepository);
|
||||||
|
registerFallbackValue(<XFile>[]);
|
||||||
|
registerFallbackValue(<int>[]);
|
||||||
});
|
});
|
||||||
|
|
||||||
tearDown(() {
|
tearDown(() {
|
||||||
@@ -21,63 +25,246 @@ void main() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('начальное состояние - BlogPostCreateState', () {
|
test('начальное состояние - BlogPostCreateState', () {
|
||||||
expect(cubit.state, isA<BlogPostCreateState>());
|
expect(cubit.state, const BlogPostCreateState());
|
||||||
});
|
});
|
||||||
|
|
||||||
test('createBlogPost - успешное создание', () async {
|
test('validateForm - пустая форма заполняет formError', () {
|
||||||
when(() => mockRepository.createBlogPost(any(), any(), any(), any()))
|
final isValid = cubit.validateForm(
|
||||||
.thenAnswer((_) async => {});
|
title: '',
|
||||||
|
description: '',
|
||||||
|
videoUrl: '',
|
||||||
|
imageCount: 0,
|
||||||
|
);
|
||||||
|
|
||||||
final result = await cubit.createBlogPost();
|
expect(isValid, false);
|
||||||
|
expect(cubit.state.formError, BlogPostFormValidator.contentRequired);
|
||||||
|
verifyNever(
|
||||||
|
() => mockRepository.createBlogPost(
|
||||||
|
title: any(named: 'title'),
|
||||||
|
videoUrl: any(named: 'videoUrl'),
|
||||||
|
description: any(named: 'description'),
|
||||||
|
images: any(named: 'images'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('validateForm - невалидное видео заполняет videoUrlError', () {
|
||||||
|
final isValid = cubit.validateForm(
|
||||||
|
title: 'Valid title',
|
||||||
|
description: '',
|
||||||
|
videoUrl: 'https://youtube.com/watch?v=abc',
|
||||||
|
imageCount: 0,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(isValid, false);
|
||||||
|
expect(
|
||||||
|
cubit.state.videoUrlError,
|
||||||
|
BlogPostFormValidator.videoUrlUnsupportedHost,
|
||||||
|
);
|
||||||
|
expect(cubit.state.formError, isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
'createBlogPost - не вызывает репозиторий при ошибке валидации',
|
||||||
|
() async {
|
||||||
|
final result = await cubit.createBlogPost();
|
||||||
|
|
||||||
|
expect(result, false);
|
||||||
|
expect(cubit.state.hasValidationErrors, true);
|
||||||
|
verifyNever(
|
||||||
|
() => mockRepository.createBlogPost(
|
||||||
|
title: any(named: 'title'),
|
||||||
|
videoUrl: any(named: 'videoUrl'),
|
||||||
|
description: any(named: 'description'),
|
||||||
|
images: any(named: 'images'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test('createBlogPost - успешное создание', () async {
|
||||||
|
when(
|
||||||
|
() => mockRepository.createBlogPost(
|
||||||
|
title: any(named: 'title'),
|
||||||
|
videoUrl: any(named: 'videoUrl'),
|
||||||
|
description: any(named: 'description'),
|
||||||
|
images: any(named: 'images'),
|
||||||
|
),
|
||||||
|
).thenAnswer((_) async => {});
|
||||||
|
|
||||||
|
final result = await cubit.createBlogPost(
|
||||||
|
title: 'Test Title',
|
||||||
|
description: 'Test Description',
|
||||||
|
);
|
||||||
|
|
||||||
expect(result, true);
|
expect(result, true);
|
||||||
expect(cubit.state, isA<BlogPostCreateStateLoaded>());
|
expect(cubit.state.isSuccess, true);
|
||||||
verify(() => mockRepository.createBlogPost(null, null, null, null)).called(1);
|
verify(
|
||||||
|
() => mockRepository.createBlogPost(
|
||||||
|
title: 'Test Title',
|
||||||
|
videoUrl: null,
|
||||||
|
description: 'Test Description',
|
||||||
|
images: const [],
|
||||||
|
),
|
||||||
|
).called(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('createBlogPost - обработка ошибки', () async {
|
test('createBlogPost - обработка ошибки', () async {
|
||||||
when(() => mockRepository.createBlogPost(any(), any(), any(), any()))
|
when(
|
||||||
.thenThrow(Exception('Error'));
|
() => mockRepository.createBlogPost(
|
||||||
|
title: any(named: 'title'),
|
||||||
|
videoUrl: any(named: 'videoUrl'),
|
||||||
|
description: any(named: 'description'),
|
||||||
|
images: any(named: 'images'),
|
||||||
|
),
|
||||||
|
).thenThrow(Exception('Error'));
|
||||||
|
|
||||||
final result = await cubit.createBlogPost();
|
final result = await cubit.createBlogPost(
|
||||||
|
title: 'Test Title',
|
||||||
|
description: 'Test Description',
|
||||||
|
);
|
||||||
|
|
||||||
expect(result, false);
|
expect(result, false);
|
||||||
expect(cubit.state, isA<BlogPostCreateStateError>());
|
expect(cubit.state.errorMessage, contains('Error'));
|
||||||
final errorState = cubit.state as BlogPostCreateStateError;
|
expect(cubit.state.isSuccess, false);
|
||||||
expect(errorState.errorMessage, contains('Error'));
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
'updateBlogPost - не вызывает репозиторий при ошибке валидации',
|
||||||
|
() async {
|
||||||
|
final result = await cubit.updateBlogPost(id: 1);
|
||||||
|
|
||||||
|
expect(result, false);
|
||||||
|
expect(cubit.state.hasValidationErrors, true);
|
||||||
|
expect(cubit.state.formError, BlogPostFormValidator.contentRequired);
|
||||||
|
verifyNever(
|
||||||
|
() => mockRepository.updateBlogPost(
|
||||||
|
id: any(named: 'id'),
|
||||||
|
title: any(named: 'title'),
|
||||||
|
videoUrl: any(named: 'videoUrl'),
|
||||||
|
description: any(named: 'description'),
|
||||||
|
existingMediaIds: any(named: 'existingMediaIds'),
|
||||||
|
newImages: any(named: 'newImages'),
|
||||||
|
mediaIdsToDelete: any(named: 'mediaIdsToDelete'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test('updateBlogPost - невалидное видео не уходит в репозиторий', () async {
|
||||||
|
final result = await cubit.updateBlogPost(
|
||||||
|
id: 1,
|
||||||
|
title: 'Valid title',
|
||||||
|
videoUrl: 'https://youtube.com/watch?v=abc',
|
||||||
|
existingMediaIds: const [10],
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result, false);
|
||||||
|
expect(
|
||||||
|
cubit.state.videoUrlError,
|
||||||
|
BlogPostFormValidator.videoUrlUnsupportedHost,
|
||||||
|
);
|
||||||
|
verifyNever(
|
||||||
|
() => mockRepository.updateBlogPost(
|
||||||
|
id: any(named: 'id'),
|
||||||
|
title: any(named: 'title'),
|
||||||
|
videoUrl: any(named: 'videoUrl'),
|
||||||
|
description: any(named: 'description'),
|
||||||
|
existingMediaIds: any(named: 'existingMediaIds'),
|
||||||
|
newImages: any(named: 'newImages'),
|
||||||
|
mediaIdsToDelete: any(named: 'mediaIdsToDelete'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
'updateBlogPost - короткий заголовок не уходит в репозиторий',
|
||||||
|
() async {
|
||||||
|
final result = await cubit.updateBlogPost(
|
||||||
|
id: 1,
|
||||||
|
title: 'ab',
|
||||||
|
existingMediaIds: const [10],
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result, false);
|
||||||
|
expect(cubit.state.titleError, BlogPostFormValidator.titleTooShort);
|
||||||
|
verifyNever(
|
||||||
|
() => mockRepository.updateBlogPost(
|
||||||
|
id: any(named: 'id'),
|
||||||
|
title: any(named: 'title'),
|
||||||
|
videoUrl: any(named: 'videoUrl'),
|
||||||
|
description: any(named: 'description'),
|
||||||
|
existingMediaIds: any(named: 'existingMediaIds'),
|
||||||
|
newImages: any(named: 'newImages'),
|
||||||
|
mediaIdsToDelete: any(named: 'mediaIdsToDelete'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
test('updateBlogPost - успешное обновление', () async {
|
test('updateBlogPost - успешное обновление', () async {
|
||||||
const id = 1;
|
const id = 1;
|
||||||
const title = 'Updated Title';
|
const title = 'Updated Title';
|
||||||
|
|
||||||
when(() => mockRepository.updateBlogPost(any(), any(), any(), any(), any(), any()))
|
when(
|
||||||
.thenAnswer((_) async => {});
|
() => mockRepository.updateBlogPost(
|
||||||
|
id: any(named: 'id'),
|
||||||
|
title: any(named: 'title'),
|
||||||
|
videoUrl: any(named: 'videoUrl'),
|
||||||
|
description: any(named: 'description'),
|
||||||
|
existingMediaIds: any(named: 'existingMediaIds'),
|
||||||
|
newImages: any(named: 'newImages'),
|
||||||
|
mediaIdsToDelete: any(named: 'mediaIdsToDelete'),
|
||||||
|
),
|
||||||
|
).thenAnswer((_) async => {});
|
||||||
|
|
||||||
final result = await cubit.updateBlogPost(id: id, title: title);
|
final result = await cubit.updateBlogPost(
|
||||||
|
id: id,
|
||||||
|
title: title,
|
||||||
|
existingMediaIds: [2, 1],
|
||||||
|
mediaIdsToDelete: [9],
|
||||||
|
);
|
||||||
|
|
||||||
expect(result, true);
|
expect(result, true);
|
||||||
expect(cubit.state, isA<BlogPostCreateStateLoaded>());
|
expect(cubit.state.isSuccess, true);
|
||||||
verify(() => mockRepository.updateBlogPost(id, title, null, null, null, null)).called(1);
|
verify(
|
||||||
|
() => mockRepository.updateBlogPost(
|
||||||
|
id: id,
|
||||||
|
title: title,
|
||||||
|
videoUrl: '',
|
||||||
|
description: '',
|
||||||
|
existingMediaIds: [2, 1],
|
||||||
|
newImages: const [],
|
||||||
|
mediaIdsToDelete: [9],
|
||||||
|
),
|
||||||
|
).called(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('updateBlogPost - обработка ошибки', () async {
|
test('updateBlogPost - обработка ошибки', () async {
|
||||||
const id = 1;
|
const id = 1;
|
||||||
|
|
||||||
when(() => mockRepository.updateBlogPost(any(), any(), any(), any(), any(), any()))
|
when(
|
||||||
.thenThrow(Exception('Error'));
|
() => mockRepository.updateBlogPost(
|
||||||
|
id: any(named: 'id'),
|
||||||
|
title: any(named: 'title'),
|
||||||
|
videoUrl: any(named: 'videoUrl'),
|
||||||
|
description: any(named: 'description'),
|
||||||
|
existingMediaIds: any(named: 'existingMediaIds'),
|
||||||
|
newImages: any(named: 'newImages'),
|
||||||
|
mediaIdsToDelete: any(named: 'mediaIdsToDelete'),
|
||||||
|
),
|
||||||
|
).thenThrow(Exception('Error'));
|
||||||
|
|
||||||
final result = await cubit.updateBlogPost(id: id);
|
final result = await cubit.updateBlogPost(id: id, title: 'Valid title');
|
||||||
|
|
||||||
expect(result, false);
|
expect(result, false);
|
||||||
expect(cubit.state, isA<BlogPostCreateStateError>());
|
expect(cubit.state.errorMessage, contains('Error'));
|
||||||
});
|
});
|
||||||
|
|
||||||
test('deleteBlogPost - успешное удаление', () async {
|
test('deleteBlogPost - успешное удаление', () async {
|
||||||
const id = 1;
|
const id = 1;
|
||||||
|
|
||||||
when(() => mockRepository.deleteBlogPost(id))
|
when(() => mockRepository.deleteBlogPost(id)).thenAnswer((_) async => {});
|
||||||
.thenAnswer((_) async => {});
|
|
||||||
|
|
||||||
final result = await cubit.deleteBlogPost(id);
|
final result = await cubit.deleteBlogPost(id);
|
||||||
|
|
||||||
@@ -88,13 +275,27 @@ void main() {
|
|||||||
test('deleteBlogPost - обработка ошибки', () async {
|
test('deleteBlogPost - обработка ошибки', () async {
|
||||||
const id = 1;
|
const id = 1;
|
||||||
|
|
||||||
when(() => mockRepository.deleteBlogPost(id))
|
when(
|
||||||
.thenThrow(Exception('Error'));
|
() => mockRepository.deleteBlogPost(id),
|
||||||
|
).thenThrow(Exception('Error'));
|
||||||
|
|
||||||
final result = await cubit.deleteBlogPost(id);
|
final result = await cubit.deleteBlogPost(id);
|
||||||
|
|
||||||
expect(result, false);
|
expect(result, false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('clearTitleError очищает titleError и formError', () {
|
||||||
|
cubit.validateForm(
|
||||||
|
title: '',
|
||||||
|
description: '',
|
||||||
|
videoUrl: '',
|
||||||
|
imageCount: 0,
|
||||||
|
);
|
||||||
|
expect(cubit.state.formError, isNotNull);
|
||||||
|
|
||||||
|
cubit.clearTitleError();
|
||||||
|
|
||||||
|
expect(cubit.state.formError, isNull);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,10 +21,16 @@ void main() {
|
|||||||
|
|
||||||
setUp(() {
|
setUp(() {
|
||||||
blogPostRepository = FixtureBlogPostRepository();
|
blogPostRepository = FixtureBlogPostRepository();
|
||||||
blogPostCreateCubit = BlogPostCreateCubit(blogPostRepository: blogPostRepository);
|
blogPostCreateCubit = BlogPostCreateCubit(
|
||||||
|
blogPostRepository: blogPostRepository,
|
||||||
|
);
|
||||||
feedProbe = FeedReloadProbe();
|
feedProbe = FeedReloadProbe();
|
||||||
fullCreateFixture = FixtureReader.readAsMap('blog_post_create/create_request_full.json');
|
fullCreateFixture = FixtureReader.readAsMap(
|
||||||
updateFixture = FixtureReader.readAsMap('blog_post_create/update_request.json');
|
'blog_post_create/create_request_full.json',
|
||||||
|
);
|
||||||
|
updateFixture = FixtureReader.readAsMap(
|
||||||
|
'blog_post_create/update_request.json',
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
tearDown(() {
|
tearDown(() {
|
||||||
@@ -41,11 +47,14 @@ void main() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
testWidgets('should complete full blog post creation flow', (WidgetTester tester) async {
|
testWidgets('should complete full blog post creation flow', (
|
||||||
|
WidgetTester tester,
|
||||||
|
) async {
|
||||||
// Arrange
|
// Arrange
|
||||||
Widget fullFlowWidget = BlocListener<BlogPostCreateCubit, BlogPostCreateState>(
|
Widget
|
||||||
|
fullFlowWidget = BlocListener<BlogPostCreateCubit, BlogPostCreateState>(
|
||||||
listener: (context, state) {
|
listener: (context, state) {
|
||||||
if (state is BlogPostCreateStateLoaded) {
|
if (state.isSuccess) {
|
||||||
feedProbe.loadFeedBlogPostList();
|
feedProbe.loadFeedBlogPostList();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -82,7 +91,11 @@ void main() {
|
|||||||
child: Column(
|
child: Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
Icon(Icons.add_photo_alternate, size: 48, color: Colors.grey),
|
Icon(
|
||||||
|
Icons.add_photo_alternate,
|
||||||
|
size: 48,
|
||||||
|
color: Colors.grey,
|
||||||
|
),
|
||||||
SizedBox(height: 8),
|
SizedBox(height: 8),
|
||||||
Text('Добавить изображение'),
|
Text('Добавить изображение'),
|
||||||
],
|
],
|
||||||
@@ -98,7 +111,6 @@ void main() {
|
|||||||
title: fullCreateFixture['title'] as String?,
|
title: fullCreateFixture['title'] as String?,
|
||||||
videoUrl: fullCreateFixture['video_url'] as String?,
|
videoUrl: fullCreateFixture['video_url'] as String?,
|
||||||
description: fullCreateFixture['description_md'] as String?,
|
description: fullCreateFixture['description_md'] as String?,
|
||||||
image: null,
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
child: const Text('Опубликовать'),
|
child: const Text('Опубликовать'),
|
||||||
@@ -111,27 +123,46 @@ void main() {
|
|||||||
await tester.pumpWidget(createTestWidget(fullFlowWidget));
|
await tester.pumpWidget(createTestWidget(fullFlowWidget));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
await tester.enterText(find.byKey(AppKeys.blogPostCreateScreenTitleTextField), fullCreateFixture['title'] as String);
|
await tester.enterText(
|
||||||
await tester.enterText(find.byKey(AppKeys.blogPostCreateScreenContentTextField), fullCreateFixture['description_md'] as String);
|
find.byKey(AppKeys.blogPostCreateScreenTitleTextField),
|
||||||
await tester.enterText(find.byKey(AppKeys.blogPostCreateScreenVideoLinkTextField), fullCreateFixture['video_url'] as String);
|
fullCreateFixture['title'] as String,
|
||||||
|
);
|
||||||
|
await tester.enterText(
|
||||||
|
find.byKey(AppKeys.blogPostCreateScreenContentTextField),
|
||||||
|
fullCreateFixture['description_md'] as String,
|
||||||
|
);
|
||||||
|
await tester.enterText(
|
||||||
|
find.byKey(AppKeys.blogPostCreateScreenVideoLinkTextField),
|
||||||
|
fullCreateFixture['video_url'] as String,
|
||||||
|
);
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
|
|
||||||
await tester.tap(find.byKey(AppKeys.blogPostCreateScreenCreateButton));
|
await tester.tap(find.byKey(AppKeys.blogPostCreateScreenCreateButton));
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
expect(blogPostRepository.createPayloads.single.toJson(), fullCreateFixture);
|
expect(
|
||||||
|
blogPostRepository.createPayloads.single.toJson(),
|
||||||
|
fullCreateFixture,
|
||||||
|
);
|
||||||
expect(feedProbe.loadRequests, 1);
|
expect(feedProbe.loadRequests, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('should handle blog post update flow', (WidgetTester tester) async {
|
testWidgets('should handle blog post update flow', (
|
||||||
|
WidgetTester tester,
|
||||||
|
) async {
|
||||||
// Arrange
|
// Arrange
|
||||||
final existingBlogPost = FeedBlogPostModel(
|
final existingBlogPost = FeedBlogPostModel(
|
||||||
id: 1,
|
id: 1,
|
||||||
state: 'published',
|
state: 'published',
|
||||||
image: 'original_image.jpg',
|
image: 'original_image.jpg',
|
||||||
description: 'Original Description',
|
description: 'Original Description',
|
||||||
author: const Author(authorSlug: 'test-author', authorName: 'Test Author', authorProfileIcon: 'avatar.jpg', authorIsMe: true),
|
author: const Author(
|
||||||
|
authorSlug: 'test-author',
|
||||||
|
authorName: 'Test Author',
|
||||||
|
authorProfileIcon: 'avatar.jpg',
|
||||||
|
authorIsMe: true,
|
||||||
|
),
|
||||||
videoUrl: 'https://youtube.com/watch?v=original',
|
videoUrl: 'https://youtube.com/watch?v=original',
|
||||||
createdAt: DateTime.now(),
|
createdAt: DateTime.now(),
|
||||||
updatedAt: DateTime.now(),
|
updatedAt: DateTime.now(),
|
||||||
@@ -142,9 +173,10 @@ void main() {
|
|||||||
wasLikedByMe: false,
|
wasLikedByMe: false,
|
||||||
);
|
);
|
||||||
|
|
||||||
Widget updateFlowWidget = BlocListener<BlogPostCreateCubit, BlogPostCreateState>(
|
Widget
|
||||||
|
updateFlowWidget = BlocListener<BlogPostCreateCubit, BlogPostCreateState>(
|
||||||
listener: (context, state) {
|
listener: (context, state) {
|
||||||
if (state is BlogPostCreateStateLoaded) {
|
if (state.isSuccess) {
|
||||||
feedProbe.loadFeedBlogPostList();
|
feedProbe.loadFeedBlogPostList();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -161,13 +193,17 @@ void main() {
|
|||||||
decoration: const InputDecoration(labelText: 'Описание'),
|
decoration: const InputDecoration(labelText: 'Описание'),
|
||||||
maxLines: 6,
|
maxLines: 6,
|
||||||
minLines: 4,
|
minLines: 4,
|
||||||
controller: TextEditingController(text: existingBlogPost.description),
|
controller: TextEditingController(
|
||||||
|
text: existingBlogPost.description,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
TextFormField(
|
TextFormField(
|
||||||
key: AppKeys.blogPostCreateScreenVideoLinkTextField,
|
key: AppKeys.blogPostCreateScreenVideoLinkTextField,
|
||||||
decoration: const InputDecoration(labelText: 'Ссылка на видео'),
|
decoration: const InputDecoration(labelText: 'Ссылка на видео'),
|
||||||
controller: TextEditingController(text: existingBlogPost.videoUrl),
|
controller: TextEditingController(
|
||||||
|
text: existingBlogPost.videoUrl,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
ElevatedButton(
|
ElevatedButton(
|
||||||
@@ -178,8 +214,9 @@ void main() {
|
|||||||
title: updateFixture['title'] as String?,
|
title: updateFixture['title'] as String?,
|
||||||
videoUrl: updateFixture['video_url'] as String?,
|
videoUrl: updateFixture['video_url'] as String?,
|
||||||
description: updateFixture['description_md'] as String?,
|
description: updateFixture['description_md'] as String?,
|
||||||
image: null,
|
existingMediaIds: List<int>.from(
|
||||||
oldImageUrl: updateFixture['old_image_url'] as String?,
|
updateFixture['existing_media_ids'] as List,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
child: const Text('Обновить'),
|
child: const Text('Обновить'),
|
||||||
@@ -192,9 +229,18 @@ void main() {
|
|||||||
await tester.pumpWidget(createTestWidget(updateFlowWidget));
|
await tester.pumpWidget(createTestWidget(updateFlowWidget));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
await tester.enterText(find.byKey(AppKeys.blogPostCreateScreenTitleTextField), updateFixture['title'] as String);
|
await tester.enterText(
|
||||||
await tester.enterText(find.byKey(AppKeys.blogPostCreateScreenContentTextField), updateFixture['description_md'] as String);
|
find.byKey(AppKeys.blogPostCreateScreenTitleTextField),
|
||||||
await tester.enterText(find.byKey(AppKeys.blogPostCreateScreenVideoLinkTextField), updateFixture['video_url'] as String);
|
updateFixture['title'] as String,
|
||||||
|
);
|
||||||
|
await tester.enterText(
|
||||||
|
find.byKey(AppKeys.blogPostCreateScreenContentTextField),
|
||||||
|
updateFixture['description_md'] as String,
|
||||||
|
);
|
||||||
|
await tester.enterText(
|
||||||
|
find.byKey(AppKeys.blogPostCreateScreenVideoLinkTextField),
|
||||||
|
updateFixture['video_url'] as String,
|
||||||
|
);
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
|
|
||||||
await tester.tap(find.byKey(AppKeys.blogPostCreateScreenCreateButton));
|
await tester.tap(find.byKey(AppKeys.blogPostCreateScreenCreateButton));
|
||||||
@@ -205,7 +251,9 @@ void main() {
|
|||||||
expect(feedProbe.loadRequests, 1);
|
expect(feedProbe.loadRequests, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('should handle blog post deletion flow', (WidgetTester tester) async {
|
testWidgets('should handle blog post deletion flow', (
|
||||||
|
WidgetTester tester,
|
||||||
|
) async {
|
||||||
// Arrange
|
// Arrange
|
||||||
Widget deleteFlowWidget = Column(
|
Widget deleteFlowWidget = Column(
|
||||||
children: [
|
children: [
|
||||||
@@ -230,35 +278,44 @@ void main() {
|
|||||||
await tester.pump();
|
await tester.pump();
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
final deleteFixture = FixtureReader.readAsMap('blog_post_create/delete_request.json');
|
final deleteFixture = FixtureReader.readAsMap(
|
||||||
|
'blog_post_create/delete_request.json',
|
||||||
|
);
|
||||||
expect(blogPostRepository.deleteCalls.single, deleteFixture['id']);
|
expect(blogPostRepository.deleteCalls.single, deleteFixture['id']);
|
||||||
expect(feedProbe.loadRequests, 1);
|
expect(feedProbe.loadRequests, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('should handle error flow during blog post creation', (WidgetTester tester) async {
|
testWidgets('should handle error flow during blog post creation', (
|
||||||
|
WidgetTester tester,
|
||||||
|
) async {
|
||||||
// Arrange
|
// Arrange
|
||||||
blogPostRepository.createError = Exception('Network error');
|
blogPostRepository.createError = Exception('Network error');
|
||||||
|
|
||||||
Widget errorFlowWidget = BlocBuilder<BlogPostCreateCubit, BlogPostCreateState>(
|
Widget errorFlowWidget =
|
||||||
builder: (context, state) {
|
BlocBuilder<BlogPostCreateCubit, BlogPostCreateState>(
|
||||||
if (state is BlogPostCreateStateError) {
|
builder: (context, state) {
|
||||||
return Column(
|
if (state.errorMessage != null) {
|
||||||
children: [
|
return Column(
|
||||||
Text('Error: ${state.errorMessage}'),
|
children: [
|
||||||
const SizedBox(height: 16),
|
Text('Error: ${state.errorMessage}'),
|
||||||
ElevatedButton(
|
const SizedBox(height: 16),
|
||||||
key: const Key('retryButton'),
|
ElevatedButton(
|
||||||
onPressed: () async {
|
key: const Key('retryButton'),
|
||||||
await blogPostCreateCubit.createBlogPost(title: 'Retry Title', videoUrl: null, description: 'Retry Description', image: null);
|
onPressed: () async {
|
||||||
},
|
await blogPostCreateCubit.createBlogPost(
|
||||||
child: const Text('Повторить'),
|
title: 'Retry Title',
|
||||||
),
|
videoUrl: null,
|
||||||
],
|
description: 'Retry Description',
|
||||||
);
|
);
|
||||||
}
|
},
|
||||||
return const Text('No Error');
|
child: const Text('Повторить'),
|
||||||
},
|
),
|
||||||
);
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return const Text('No Error');
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
await tester.pumpWidget(createTestWidget(errorFlowWidget));
|
await tester.pumpWidget(createTestWidget(errorFlowWidget));
|
||||||
@@ -269,7 +326,6 @@ void main() {
|
|||||||
title: fullCreateFixture['title'] as String?,
|
title: fullCreateFixture['title'] as String?,
|
||||||
videoUrl: null,
|
videoUrl: null,
|
||||||
description: fullCreateFixture['description_md'] as String?,
|
description: fullCreateFixture['description_md'] as String?,
|
||||||
image: null,
|
|
||||||
);
|
);
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
|
|
||||||
@@ -278,18 +334,27 @@ void main() {
|
|||||||
expect(find.byKey(const Key('retryButton')), findsOneWidget);
|
expect(find.byKey(const Key('retryButton')), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('should handle loading state during blog post creation', (WidgetTester tester) async {
|
testWidgets('should handle loading state during blog post creation', (
|
||||||
|
WidgetTester tester,
|
||||||
|
) async {
|
||||||
// Arrange
|
// Arrange
|
||||||
blogPostRepository.createDelay = const Duration(milliseconds: 50);
|
blogPostRepository.createDelay = const Duration(milliseconds: 50);
|
||||||
|
|
||||||
Widget loadingFlowWidget = BlocBuilder<BlogPostCreateCubit, BlogPostCreateState>(
|
Widget loadingFlowWidget =
|
||||||
builder: (context, state) {
|
BlocBuilder<BlogPostCreateCubit, BlogPostCreateState>(
|
||||||
if (state is BlogPostCreateStateLoading) {
|
builder: (context, state) {
|
||||||
return const Column(children: [CircularProgressIndicator(), SizedBox(height: 16), Text('Создание блог поста...')]);
|
if (state.isLoading) {
|
||||||
}
|
return const Column(
|
||||||
return const Text('Not Loading');
|
children: [
|
||||||
},
|
CircularProgressIndicator(),
|
||||||
);
|
SizedBox(height: 16),
|
||||||
|
Text('Создание блог поста...'),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return const Text('Not Loading');
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
await tester.pumpWidget(createTestWidget(loadingFlowWidget));
|
await tester.pumpWidget(createTestWidget(loadingFlowWidget));
|
||||||
@@ -300,7 +365,6 @@ void main() {
|
|||||||
title: fullCreateFixture['title'] as String?,
|
title: fullCreateFixture['title'] as String?,
|
||||||
videoUrl: null,
|
videoUrl: null,
|
||||||
description: fullCreateFixture['description_md'] as String?,
|
description: fullCreateFixture['description_md'] as String?,
|
||||||
image: null,
|
|
||||||
);
|
);
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
await tester.pump(); // Need to pump twice to get the loading state
|
await tester.pump(); // Need to pump twice to get the loading state
|
||||||
@@ -314,29 +378,32 @@ void main() {
|
|||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('should handle success state after blog post creation', (WidgetTester tester) async {
|
testWidgets('should handle success state after blog post creation', (
|
||||||
|
WidgetTester tester,
|
||||||
|
) async {
|
||||||
// Arrange
|
// Arrange
|
||||||
Widget successFlowWidget = BlocListener<BlogPostCreateCubit, BlogPostCreateState>(
|
Widget successFlowWidget =
|
||||||
listener: (context, state) {
|
BlocListener<BlogPostCreateCubit, BlogPostCreateState>(
|
||||||
if (state is BlogPostCreateStateLoaded) {
|
listener: (context, state) {
|
||||||
feedProbe.loadFeedBlogPostList();
|
if (state.isSuccess) {
|
||||||
}
|
feedProbe.loadFeedBlogPostList();
|
||||||
},
|
}
|
||||||
child: BlocBuilder<BlogPostCreateCubit, BlogPostCreateState>(
|
},
|
||||||
builder: (context, state) {
|
child: BlocBuilder<BlogPostCreateCubit, BlogPostCreateState>(
|
||||||
if (state is BlogPostCreateStateLoaded) {
|
builder: (context, state) {
|
||||||
return const Column(
|
if (state.isSuccess) {
|
||||||
children: [
|
return const Column(
|
||||||
Icon(Icons.check_circle, color: Colors.green, size: 48),
|
children: [
|
||||||
SizedBox(height: 16),
|
Icon(Icons.check_circle, color: Colors.green, size: 48),
|
||||||
Text('Блог пост успешно создан!'),
|
SizedBox(height: 16),
|
||||||
],
|
Text('Блог пост успешно создан!'),
|
||||||
);
|
],
|
||||||
}
|
);
|
||||||
return const Text('Not Loaded');
|
}
|
||||||
},
|
return const Text('Not Loaded');
|
||||||
),
|
},
|
||||||
);
|
),
|
||||||
|
);
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
await tester.pumpWidget(createTestWidget(successFlowWidget));
|
await tester.pumpWidget(createTestWidget(successFlowWidget));
|
||||||
@@ -347,7 +414,6 @@ void main() {
|
|||||||
title: fullCreateFixture['title'] as String?,
|
title: fullCreateFixture['title'] as String?,
|
||||||
videoUrl: null,
|
videoUrl: null,
|
||||||
description: fullCreateFixture['description_md'] as String?,
|
description: fullCreateFixture['description_md'] as String?,
|
||||||
image: null,
|
|
||||||
);
|
);
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
|
|
||||||
@@ -357,28 +423,43 @@ void main() {
|
|||||||
expect(feedProbe.loadRequests, 1);
|
expect(feedProbe.loadRequests, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('should handle form validation flow', (WidgetTester tester) async {
|
testWidgets('should handle form validation flow', (
|
||||||
|
WidgetTester tester,
|
||||||
|
) async {
|
||||||
// Arrange
|
// Arrange
|
||||||
Widget validationFlowWidget = Column(
|
Widget validationFlowWidget = Column(
|
||||||
children: [
|
children: [
|
||||||
TextFormField(
|
TextFormField(
|
||||||
key: AppKeys.blogPostCreateScreenTitleTextField,
|
key: AppKeys.blogPostCreateScreenTitleTextField,
|
||||||
decoration: const InputDecoration(labelText: 'Заголовок', errorText: 'Заголовок обязателен'),
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Заголовок',
|
||||||
|
errorText: 'Заголовок обязателен',
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
TextFormField(
|
TextFormField(
|
||||||
key: AppKeys.blogPostCreateScreenContentTextField,
|
key: AppKeys.blogPostCreateScreenContentTextField,
|
||||||
decoration: const InputDecoration(labelText: 'Описание', errorText: 'Описание обязательно'),
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Описание',
|
||||||
|
errorText: 'Описание обязательно',
|
||||||
|
),
|
||||||
maxLines: 6,
|
maxLines: 6,
|
||||||
minLines: 4,
|
minLines: 4,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
TextFormField(
|
TextFormField(
|
||||||
key: AppKeys.blogPostCreateScreenVideoLinkTextField,
|
key: AppKeys.blogPostCreateScreenVideoLinkTextField,
|
||||||
decoration: const InputDecoration(labelText: 'Ссылка на видео', errorText: 'Введите корректную ссылку'),
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Ссылка на видео',
|
||||||
|
errorText: 'Введите корректную ссылку',
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
ElevatedButton(key: AppKeys.blogPostCreateScreenCreateButton, onPressed: () {}, child: const Text('Опубликовать')),
|
ElevatedButton(
|
||||||
|
key: AppKeys.blogPostCreateScreenCreateButton,
|
||||||
|
onPressed: () {},
|
||||||
|
child: const Text('Опубликовать'),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -415,7 +496,11 @@ void main() {
|
|||||||
child: Column(
|
child: Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
Icon(Icons.add_photo_alternate, size: 48, color: Colors.grey),
|
Icon(
|
||||||
|
Icons.add_photo_alternate,
|
||||||
|
size: 48,
|
||||||
|
color: Colors.grey,
|
||||||
|
),
|
||||||
SizedBox(height: 8),
|
SizedBox(height: 8),
|
||||||
Text('Добавить изображение'),
|
Text('Добавить изображение'),
|
||||||
],
|
],
|
||||||
@@ -431,7 +516,6 @@ void main() {
|
|||||||
title: 'Test Title',
|
title: 'Test Title',
|
||||||
videoUrl: null,
|
videoUrl: null,
|
||||||
description: 'Test Description',
|
description: 'Test Description',
|
||||||
image: null, // В реальном тесте здесь был бы XFile
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
child: const Text('Опубликовать'),
|
child: const Text('Опубликовать'),
|
||||||
@@ -450,90 +534,122 @@ void main() {
|
|||||||
expect(imageUploadInitiated, isTrue);
|
expect(imageUploadInitiated, isTrue);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('should handle complete blog post creation with all features', (WidgetTester tester) async {
|
testWidgets('should handle complete blog post creation with all features', (
|
||||||
|
WidgetTester tester,
|
||||||
|
) async {
|
||||||
// Arrange
|
// Arrange
|
||||||
Widget completeFlowWidget = BlocListener<BlogPostCreateCubit, BlogPostCreateState>(
|
Widget completeFlowWidget =
|
||||||
listener: (context, state) {
|
BlocListener<BlogPostCreateCubit, BlogPostCreateState>(
|
||||||
if (state is BlogPostCreateStateLoaded) {
|
listener: (context, state) {
|
||||||
feedProbe.loadFeedBlogPostList();
|
if (state.isSuccess) {
|
||||||
}
|
feedProbe.loadFeedBlogPostList();
|
||||||
},
|
}
|
||||||
child: Column(
|
},
|
||||||
children: [
|
child: Column(
|
||||||
const Text('Заголовок'),
|
children: [
|
||||||
const SizedBox(height: 8),
|
const Text('Заголовок'),
|
||||||
TextFormField(
|
const SizedBox(height: 8),
|
||||||
key: AppKeys.blogPostCreateScreenTitleTextField,
|
TextFormField(
|
||||||
decoration: const InputDecoration(hintText: 'Введите заголовок поста'),
|
key: AppKeys.blogPostCreateScreenTitleTextField,
|
||||||
),
|
decoration: const InputDecoration(
|
||||||
const SizedBox(height: 16),
|
hintText: 'Введите заголовок поста',
|
||||||
const Text('Описание'),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
TextFormField(key: AppKeys.blogPostCreateScreenContentTextField, decoration: const InputDecoration(), maxLines: 6, minLines: 4),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
const Text('Изображение поста'),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
GestureDetector(
|
|
||||||
key: AppKeys.imageWidget,
|
|
||||||
onTap: () {},
|
|
||||||
child: Container(
|
|
||||||
height: 200,
|
|
||||||
width: double.infinity,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
border: Border.all(color: Colors.grey),
|
|
||||||
borderRadius: BorderRadius.circular(8),
|
|
||||||
),
|
|
||||||
child: const Center(
|
|
||||||
child: Column(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
Icon(Icons.add_photo_alternate, size: 48, color: Colors.grey),
|
|
||||||
SizedBox(height: 8),
|
|
||||||
Text('Добавить изображение'),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
const SizedBox(height: 16),
|
||||||
|
const Text('Описание'),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
TextFormField(
|
||||||
|
key: AppKeys.blogPostCreateScreenContentTextField,
|
||||||
|
decoration: const InputDecoration(),
|
||||||
|
maxLines: 6,
|
||||||
|
minLines: 4,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
const Text('Изображение поста'),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
GestureDetector(
|
||||||
|
key: AppKeys.imageWidget,
|
||||||
|
onTap: () {},
|
||||||
|
child: Container(
|
||||||
|
height: 200,
|
||||||
|
width: double.infinity,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
border: Border.all(color: Colors.grey),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: const Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.add_photo_alternate,
|
||||||
|
size: 48,
|
||||||
|
color: Colors.grey,
|
||||||
|
),
|
||||||
|
SizedBox(height: 8),
|
||||||
|
Text('Добавить изображение'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
const Text('Ссылка на видео'),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
TextFormField(
|
||||||
|
key: AppKeys.blogPostCreateScreenVideoLinkTextField,
|
||||||
|
decoration: const InputDecoration(),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 32),
|
||||||
|
ElevatedButton(
|
||||||
|
key: AppKeys.blogPostCreateScreenCreateButton,
|
||||||
|
onPressed: () async {
|
||||||
|
await blogPostCreateCubit.createBlogPost(
|
||||||
|
title: fullCreateFixture['title'] as String?,
|
||||||
|
videoUrl: fullCreateFixture['video_url'] as String?,
|
||||||
|
description:
|
||||||
|
fullCreateFixture['description_md'] as String?,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: const Text('Опубликовать'),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
);
|
||||||
const Text('Ссылка на видео'),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
TextFormField(key: AppKeys.blogPostCreateScreenVideoLinkTextField, decoration: const InputDecoration()),
|
|
||||||
const SizedBox(height: 32),
|
|
||||||
ElevatedButton(
|
|
||||||
key: AppKeys.blogPostCreateScreenCreateButton,
|
|
||||||
onPressed: () async {
|
|
||||||
await blogPostCreateCubit.createBlogPost(
|
|
||||||
title: fullCreateFixture['title'] as String?,
|
|
||||||
videoUrl: fullCreateFixture['video_url'] as String?,
|
|
||||||
description: fullCreateFixture['description_md'] as String?,
|
|
||||||
image: null,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
child: const Text('Опубликовать'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
await tester.pumpWidget(createTestWidget(completeFlowWidget));
|
await tester.pumpWidget(createTestWidget(completeFlowWidget));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
await tester.enterText(find.byKey(AppKeys.blogPostCreateScreenTitleTextField), fullCreateFixture['title'] as String);
|
await tester.enterText(
|
||||||
await tester.enterText(find.byKey(AppKeys.blogPostCreateScreenContentTextField), fullCreateFixture['description_md'] as String);
|
find.byKey(AppKeys.blogPostCreateScreenTitleTextField),
|
||||||
await tester.enterText(find.byKey(AppKeys.blogPostCreateScreenVideoLinkTextField), fullCreateFixture['video_url'] as String);
|
fullCreateFixture['title'] as String,
|
||||||
|
);
|
||||||
|
await tester.enterText(
|
||||||
|
find.byKey(AppKeys.blogPostCreateScreenContentTextField),
|
||||||
|
fullCreateFixture['description_md'] as String,
|
||||||
|
);
|
||||||
|
await tester.enterText(
|
||||||
|
find.byKey(AppKeys.blogPostCreateScreenVideoLinkTextField),
|
||||||
|
fullCreateFixture['video_url'] as String,
|
||||||
|
);
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
|
|
||||||
// Scroll to make sure the button is visible
|
// Scroll to make sure the button is visible
|
||||||
await tester.drag(find.byType(SingleChildScrollView), const Offset(0, -200));
|
await tester.drag(
|
||||||
|
find.byType(SingleChildScrollView),
|
||||||
|
const Offset(0, -200),
|
||||||
|
);
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
|
|
||||||
await tester.tap(find.byKey(AppKeys.blogPostCreateScreenCreateButton));
|
await tester.tap(find.byKey(AppKeys.blogPostCreateScreenCreateButton));
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
expect(blogPostRepository.createPayloads.single.toJson(), fullCreateFixture);
|
expect(
|
||||||
|
blogPostRepository.createPayloads.single.toJson(),
|
||||||
|
fullCreateFixture,
|
||||||
|
);
|
||||||
expect(feedProbe.loadRequests, 1);
|
expect(feedProbe.loadRequests, 1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import 'package:eatvio_app/features/blog_post_create/presentation/pages/blog_pos
|
|||||||
import 'package:eatvio_app/features/blog_post_create/repositories/blog_post_repository.dart';
|
import 'package:eatvio_app/features/blog_post_create/repositories/blog_post_repository.dart';
|
||||||
import 'package:eatvio_app/features/feed/models/diets_pagination_model.dart';
|
import 'package:eatvio_app/features/feed/models/diets_pagination_model.dart';
|
||||||
import 'package:eatvio_app/features/feed/models/feed_blog_post_model.dart';
|
import 'package:eatvio_app/features/feed/models/feed_blog_post_model.dart';
|
||||||
|
import 'package:eatvio_app/features/feed/models/post_feed_pagination_model.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_cubit.dart';
|
||||||
import 'package:eatvio_app/features/feed/presentation/bloc/feed_cubit/feed_state.dart';
|
import 'package:eatvio_app/features/feed/presentation/bloc/feed_cubit/feed_state.dart';
|
||||||
import 'package:eatvio_app/generated/l10n.dart';
|
import 'package:eatvio_app/generated/l10n.dart';
|
||||||
@@ -19,14 +20,15 @@ import 'package:flutter_bloc/flutter_bloc.dart';
|
|||||||
import 'package:flutter_localizations/flutter_localizations.dart';
|
import 'package:flutter_localizations/flutter_localizations.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:mockito/annotations.dart';
|
import 'package:mocktail/mocktail.dart';
|
||||||
import 'package:mockito/mockito.dart';
|
|
||||||
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
||||||
|
import 'package:flutter_image_compress/flutter_image_compress.dart';
|
||||||
import 'package:network_image_mock/network_image_mock.dart';
|
import 'package:network_image_mock/network_image_mock.dart';
|
||||||
|
|
||||||
import 'blog_post_create_screen_test.mocks.dart';
|
class MockBlogPostRepository extends Mock implements BlogPostRepository {}
|
||||||
|
|
||||||
|
class MockFeedCubit extends Mock implements FeedCubit {}
|
||||||
|
|
||||||
@GenerateMocks([BlogPostRepository, BlogPostCreateCubit, FeedCubit])
|
|
||||||
void main() {
|
void main() {
|
||||||
TestWidgetsFlutterBinding.ensureInitialized();
|
TestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
@@ -37,13 +39,22 @@ void main() {
|
|||||||
|
|
||||||
setUp(() async {
|
setUp(() async {
|
||||||
await sl.reset();
|
await sl.reset();
|
||||||
dotenv.loadFromString(envString: 'DEV_BASE_URL=http://localhost\nBASE_URL=http://localhost');
|
dotenv.loadFromString(
|
||||||
|
envString: 'DEV_BASE_URL=http://localhost\nBASE_URL=http://localhost',
|
||||||
|
);
|
||||||
AppConfigRegistration.registerAppConfig(buildType: AppBuildType.tests);
|
AppConfigRegistration.registerAppConfig(buildType: AppBuildType.tests);
|
||||||
|
registerFallbackValue(<XFile>[]);
|
||||||
|
registerFallbackValue(<int>[]);
|
||||||
mockBlogPostRepository = MockBlogPostRepository();
|
mockBlogPostRepository = MockBlogPostRepository();
|
||||||
blogPostCreateCubit = BlogPostCreateCubit(blogPostRepository: mockBlogPostRepository);
|
blogPostCreateCubit = BlogPostCreateCubit(
|
||||||
|
blogPostRepository: mockBlogPostRepository,
|
||||||
|
);
|
||||||
mockFeedCubit = MockFeedCubit();
|
mockFeedCubit = MockFeedCubit();
|
||||||
when(mockFeedCubit.state).thenReturn(const FeedState.loading());
|
when(() => mockFeedCubit.state).thenReturn(const FeedState.loading());
|
||||||
when(mockFeedCubit.stream).thenAnswer((_) => Stream.value(const FeedState.loading()));
|
when(
|
||||||
|
() => mockFeedCubit.stream,
|
||||||
|
).thenAnswer((_) => Stream.value(const FeedState.loading()));
|
||||||
|
when(() => mockFeedCubit.loadFeedBlogPostList()).thenAnswer((_) async {});
|
||||||
sl.registerLazySingleton<FeedCubit>(() => mockFeedCubit);
|
sl.registerLazySingleton<FeedCubit>(() => mockFeedCubit);
|
||||||
sl.registerLazySingleton<BlogPostCreateCubit>(() => blogPostCreateCubit);
|
sl.registerLazySingleton<BlogPostCreateCubit>(() => blogPostCreateCubit);
|
||||||
});
|
});
|
||||||
@@ -52,19 +63,25 @@ void main() {
|
|||||||
blogPostCreateCubit.close();
|
blogPostCreateCubit.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
Widget createTestWidget({FeedBlogPostModel? feedBlogPostModel, bool canPop = false}) {
|
Widget createTestWidget({
|
||||||
|
FeedBlogPostModel? feedBlogPostModel,
|
||||||
|
bool canPop = false,
|
||||||
|
}) {
|
||||||
final router = GoRouter(
|
final router = GoRouter(
|
||||||
initialLocation: '/blog-post-create',
|
initialLocation: '/blog-post-create',
|
||||||
routes: [
|
routes: [
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/home',
|
path: '/home',
|
||||||
builder: (context, state) => const Scaffold(body: Center(child: Text('Home'))),
|
builder: (context, state) =>
|
||||||
|
const Scaffold(body: Center(child: Text('Home'))),
|
||||||
),
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/blog-post-create',
|
path: '/blog-post-create',
|
||||||
builder: (context, state) => MultiBlocProvider(
|
builder: (context, state) => MultiBlocProvider(
|
||||||
providers: [
|
providers: [
|
||||||
BlocProvider<BlogPostCreateCubit>.value(value: blogPostCreateCubit),
|
BlocProvider<BlogPostCreateCubit>.value(
|
||||||
|
value: blogPostCreateCubit,
|
||||||
|
),
|
||||||
BlocProvider<FeedCubit>.value(value: mockFeedCubit),
|
BlocProvider<FeedCubit>.value(value: mockFeedCubit),
|
||||||
],
|
],
|
||||||
child: BlogPostCreateScreen(feedBlogPostModel: feedBlogPostModel),
|
child: BlogPostCreateScreen(feedBlogPostModel: feedBlogPostModel),
|
||||||
@@ -87,28 +104,46 @@ void main() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
testWidgets('displays all form fields when creating new blog post', (tester) async {
|
testWidgets('displays all form fields when creating new blog post', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
await tester.pumpWidget(createTestWidget());
|
await tester.pumpWidget(createTestWidget());
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
expect(find.text('Заголовок'), findsOneWidget);
|
expect(find.text('Заголовок'), findsOneWidget);
|
||||||
expect(find.text('Описание'), findsOneWidget);
|
expect(find.text('Описание'), findsOneWidget);
|
||||||
expect(find.text('Изображение поста'), findsOneWidget);
|
expect(find.textContaining('Изображения'), findsOneWidget);
|
||||||
expect(find.text('Ссылка на видео'), findsOneWidget);
|
expect(find.text('Ссылка на видео'), findsOneWidget);
|
||||||
expect(find.byKey(AppKeys.blogPostCreateScreenTitleTextField), findsOneWidget);
|
expect(
|
||||||
expect(find.byKey(AppKeys.blogPostCreateScreenContentTextField), findsOneWidget);
|
find.byKey(AppKeys.blogPostCreateScreenTitleTextField),
|
||||||
expect(find.byKey(AppKeys.blogPostCreateScreenVideoLinkTextField), findsOneWidget);
|
findsOneWidget,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
find.byKey(AppKeys.blogPostCreateScreenContentTextField),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
find.byKey(AppKeys.blogPostCreateScreenVideoLinkTextField),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('pre-fills form fields when editing existing blog post', (tester) async {
|
testWidgets('pre-fills form fields when editing existing blog post', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
final existingPost = FeedBlogPostModel(
|
final existingPost = FeedBlogPostModel(
|
||||||
id: 1,
|
id: 1,
|
||||||
title: 'Test Title',
|
title: 'Test Title',
|
||||||
description: 'Test Description',
|
description: 'Test Description',
|
||||||
videoUrl: 'https://youtube.com/watch?v=test',
|
videoUrl: 'https://rutube.ru/video/12345/',
|
||||||
image: '', // Changed: removed image so video field is visible
|
image: '',
|
||||||
state: 'published',
|
state: 'published',
|
||||||
author: const Author(authorSlug: 'test-author', authorName: 'Test Author', authorProfileIcon: 'avatar.jpg', authorIsMe: true),
|
author: const Author(
|
||||||
|
authorSlug: 'test-author',
|
||||||
|
authorName: 'Test Author',
|
||||||
|
authorProfileIcon: 'avatar.jpg',
|
||||||
|
authorIsMe: true,
|
||||||
|
),
|
||||||
createdAt: DateTime.now(),
|
createdAt: DateTime.now(),
|
||||||
updatedAt: DateTime.now(),
|
updatedAt: DateTime.now(),
|
||||||
totalLikes: 0,
|
totalLikes: 0,
|
||||||
@@ -117,24 +152,299 @@ void main() {
|
|||||||
wasLikedByMe: false,
|
wasLikedByMe: false,
|
||||||
);
|
);
|
||||||
|
|
||||||
await tester.pumpWidget(createTestWidget(feedBlogPostModel: existingPost));
|
await tester.pumpWidget(
|
||||||
|
createTestWidget(feedBlogPostModel: existingPost),
|
||||||
|
);
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
final titleField = tester.widget<CustomTextFormField>(find.byKey(AppKeys.blogPostCreateScreenTitleTextField));
|
final titleField = tester.widget<CustomTextFormField>(
|
||||||
|
find.byKey(AppKeys.blogPostCreateScreenTitleTextField),
|
||||||
|
);
|
||||||
expect(titleField.controller?.text, 'Test Title');
|
expect(titleField.controller?.text, 'Test Title');
|
||||||
|
|
||||||
final descriptionField = tester.widget<CustomTextFormField>(find.byKey(AppKeys.blogPostCreateScreenContentTextField));
|
final descriptionField = tester.widget<CustomTextFormField>(
|
||||||
|
find.byKey(AppKeys.blogPostCreateScreenContentTextField),
|
||||||
|
);
|
||||||
expect(descriptionField.controller?.text, 'Test Description');
|
expect(descriptionField.controller?.text, 'Test Description');
|
||||||
|
|
||||||
final videoFieldFinder = find.byKey(AppKeys.blogPostCreateScreenVideoLinkTextField);
|
final videoField = tester.widget<CustomTextFormField>(
|
||||||
if (videoFieldFinder.evaluate().isNotEmpty) {
|
find.byKey(AppKeys.blogPostCreateScreenVideoLinkTextField),
|
||||||
final videoField = tester.widget<CustomTextFormField>(videoFieldFinder);
|
);
|
||||||
expect(videoField.controller?.text, 'https://youtube.com/watch?v=test');
|
expect(videoField.controller?.text, 'https://rutube.ru/video/12345/');
|
||||||
}
|
expect(find.text('Сохранить'), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('hides video field when image is present', (tester) async {
|
testWidgets('shows video and gallery fields together', (tester) async {
|
||||||
await mockNetworkImagesFor(() async {
|
await mockNetworkImagesFor(() async {
|
||||||
|
final existingPost = FeedBlogPostModel(
|
||||||
|
id: 1,
|
||||||
|
title: 'Test Title',
|
||||||
|
description: 'Test Description',
|
||||||
|
videoUrl: 'https://rutube.ru/video/12345/',
|
||||||
|
image: 'http://localhost/storage/test_image.jpg',
|
||||||
|
state: 'published',
|
||||||
|
author: const Author(
|
||||||
|
authorSlug: 'test-author',
|
||||||
|
authorName: 'Test Author',
|
||||||
|
authorProfileIcon: 'http://localhost/storage/avatar.jpg',
|
||||||
|
authorIsMe: true,
|
||||||
|
),
|
||||||
|
createdAt: DateTime.now(),
|
||||||
|
updatedAt: DateTime.now(),
|
||||||
|
totalLikes: 0,
|
||||||
|
totalViews: 0,
|
||||||
|
totalComments: 0,
|
||||||
|
wasLikedByMe: false,
|
||||||
|
images: const [
|
||||||
|
FeedGalleryImage(
|
||||||
|
id: 10,
|
||||||
|
url: 'http://localhost/storage/test_image.jpg',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
createTestWidget(feedBlogPostModel: existingPost),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('Ссылка на видео'), findsOneWidget);
|
||||||
|
expect(
|
||||||
|
find.byKey(AppKeys.blogPostCreateScreenVideoLinkTextField),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
|
expect(find.textContaining('Изображения'), findsOneWidget);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('allows entering text in title field', (tester) async {
|
||||||
|
await tester.pumpWidget(createTestWidget());
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.enterText(
|
||||||
|
find.byKey(AppKeys.blogPostCreateScreenTitleTextField),
|
||||||
|
'New Title',
|
||||||
|
);
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
final titleField = tester.widget<CustomTextFormField>(
|
||||||
|
find.byKey(AppKeys.blogPostCreateScreenTitleTextField),
|
||||||
|
);
|
||||||
|
expect(titleField.controller?.text, 'New Title');
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('allows entering text in description field', (tester) async {
|
||||||
|
await tester.pumpWidget(createTestWidget());
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.enterText(
|
||||||
|
find.byKey(AppKeys.blogPostCreateScreenContentTextField),
|
||||||
|
'New Description',
|
||||||
|
);
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
final descriptionField = tester.widget<CustomTextFormField>(
|
||||||
|
find.byKey(AppKeys.blogPostCreateScreenContentTextField),
|
||||||
|
);
|
||||||
|
expect(descriptionField.controller?.text, 'New Description');
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('allows entering text in video URL field', (tester) async {
|
||||||
|
await tester.pumpWidget(createTestWidget());
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.enterText(
|
||||||
|
find.byKey(AppKeys.blogPostCreateScreenVideoLinkTextField),
|
||||||
|
'https://youtube.com/watch?v=new',
|
||||||
|
);
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
final videoField = tester.widget<CustomTextFormField>(
|
||||||
|
find.byKey(AppKeys.blogPostCreateScreenVideoLinkTextField),
|
||||||
|
);
|
||||||
|
expect(videoField.controller?.text, 'https://youtube.com/watch?v=new');
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('displays create button', (tester) async {
|
||||||
|
await tester.pumpWidget(createTestWidget());
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('Создать'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('does not submit when form is empty', (tester) async {
|
||||||
|
when(
|
||||||
|
() => mockBlogPostRepository.createBlogPost(
|
||||||
|
title: any(named: 'title'),
|
||||||
|
videoUrl: any(named: 'videoUrl'),
|
||||||
|
description: any(named: 'description'),
|
||||||
|
images: any(named: 'images'),
|
||||||
|
),
|
||||||
|
).thenAnswer((_) async {});
|
||||||
|
|
||||||
|
await tester.pumpWidget(createTestWidget());
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
final createButton = find.text('Создать');
|
||||||
|
await tester.ensureVisible(createButton);
|
||||||
|
await tester.tap(createButton);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
verifyNever(
|
||||||
|
() => mockBlogPostRepository.createBlogPost(
|
||||||
|
title: any(named: 'title'),
|
||||||
|
videoUrl: any(named: 'videoUrl'),
|
||||||
|
description: any(named: 'description'),
|
||||||
|
images: any(named: 'images'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
find.text(
|
||||||
|
'Добавьте заголовок, описание, изображение или ссылку на видео',
|
||||||
|
),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('does not submit when video url is invalid', (tester) async {
|
||||||
|
when(
|
||||||
|
() => mockBlogPostRepository.createBlogPost(
|
||||||
|
title: any(named: 'title'),
|
||||||
|
videoUrl: any(named: 'videoUrl'),
|
||||||
|
description: any(named: 'description'),
|
||||||
|
images: any(named: 'images'),
|
||||||
|
),
|
||||||
|
).thenAnswer((_) async {});
|
||||||
|
|
||||||
|
await tester.pumpWidget(createTestWidget());
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.enterText(
|
||||||
|
find.byKey(AppKeys.blogPostCreateScreenTitleTextField),
|
||||||
|
'Test Title',
|
||||||
|
);
|
||||||
|
await tester.enterText(
|
||||||
|
find.byKey(AppKeys.blogPostCreateScreenVideoLinkTextField),
|
||||||
|
'https://youtube.com/watch?v=abc',
|
||||||
|
);
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
final createButton = find.text('Создать');
|
||||||
|
await tester.ensureVisible(createButton);
|
||||||
|
await tester.tap(createButton);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
verifyNever(
|
||||||
|
() => mockBlogPostRepository.createBlogPost(
|
||||||
|
title: any(named: 'title'),
|
||||||
|
videoUrl: any(named: 'videoUrl'),
|
||||||
|
description: any(named: 'description'),
|
||||||
|
images: any(named: 'images'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
find.text('Поддерживаются только Rutube, VK и OK'),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('shows loading state when creating blog post', (tester) async {
|
||||||
|
when(
|
||||||
|
() => mockBlogPostRepository.createBlogPost(
|
||||||
|
title: any(named: 'title'),
|
||||||
|
videoUrl: any(named: 'videoUrl'),
|
||||||
|
description: any(named: 'description'),
|
||||||
|
images: any(named: 'images'),
|
||||||
|
),
|
||||||
|
).thenAnswer((_) async {
|
||||||
|
await Future.delayed(const Duration(milliseconds: 100));
|
||||||
|
return;
|
||||||
|
});
|
||||||
|
|
||||||
|
await tester.pumpWidget(createTestWidget());
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.enterText(
|
||||||
|
find.byKey(AppKeys.blogPostCreateScreenTitleTextField),
|
||||||
|
'Test Title',
|
||||||
|
);
|
||||||
|
await tester.enterText(
|
||||||
|
find.byKey(AppKeys.blogPostCreateScreenContentTextField),
|
||||||
|
'Test Description',
|
||||||
|
);
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
final createButton = find.text('Создать');
|
||||||
|
await tester.ensureVisible(createButton);
|
||||||
|
await tester.tap(createButton);
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(find.byType(Loader), findsWidgets);
|
||||||
|
|
||||||
|
await tester.pump(const Duration(milliseconds: 150));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets(
|
||||||
|
'calls FeedCubit.loadFeedBlogPostList when blog post is created successfully',
|
||||||
|
(tester) async {
|
||||||
|
when(
|
||||||
|
() => mockBlogPostRepository.createBlogPost(
|
||||||
|
title: any(named: 'title'),
|
||||||
|
videoUrl: any(named: 'videoUrl'),
|
||||||
|
description: any(named: 'description'),
|
||||||
|
images: any(named: 'images'),
|
||||||
|
),
|
||||||
|
).thenAnswer((_) async {
|
||||||
|
return;
|
||||||
|
});
|
||||||
|
|
||||||
|
await tester.pumpWidget(createTestWidget());
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
final titleField = find.byKey(
|
||||||
|
AppKeys.blogPostCreateScreenTitleTextField,
|
||||||
|
);
|
||||||
|
final contentField = find.byKey(
|
||||||
|
AppKeys.blogPostCreateScreenContentTextField,
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.ensureVisible(titleField);
|
||||||
|
await tester.ensureVisible(contentField);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.enterText(titleField, 'Test Title');
|
||||||
|
await tester.enterText(contentField, 'Test Description');
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
final createButton = find.text('Создать');
|
||||||
|
await tester.ensureVisible(createButton);
|
||||||
|
await tester.tap(createButton, warnIfMissed: false);
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
await tester.pump(const Duration(milliseconds: 200));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
verify(() => mockFeedCubit.loadFeedBlogPostList()).called(1);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
testWidgets('does not submit update when video url is invalid', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
await mockNetworkImagesFor(() async {
|
||||||
|
when(
|
||||||
|
() => mockBlogPostRepository.updateBlogPost(
|
||||||
|
id: any(named: 'id'),
|
||||||
|
title: any(named: 'title'),
|
||||||
|
videoUrl: any(named: 'videoUrl'),
|
||||||
|
description: any(named: 'description'),
|
||||||
|
existingMediaIds: any(named: 'existingMediaIds'),
|
||||||
|
newImages: any(named: 'newImages'),
|
||||||
|
mediaIdsToDelete: any(named: 'mediaIdsToDelete'),
|
||||||
|
),
|
||||||
|
).thenAnswer((_) async {});
|
||||||
|
|
||||||
final existingPost = FeedBlogPostModel(
|
final existingPost = FeedBlogPostModel(
|
||||||
id: 1,
|
id: 1,
|
||||||
title: 'Test Title',
|
title: 'Test Title',
|
||||||
@@ -154,136 +464,140 @@ void main() {
|
|||||||
totalViews: 0,
|
totalViews: 0,
|
||||||
totalComments: 0,
|
totalComments: 0,
|
||||||
wasLikedByMe: false,
|
wasLikedByMe: false,
|
||||||
|
images: const [
|
||||||
|
FeedGalleryImage(
|
||||||
|
id: 10,
|
||||||
|
url: 'http://localhost/storage/test_image.jpg',
|
||||||
|
),
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
await tester.pumpWidget(createTestWidget(feedBlogPostModel: existingPost));
|
await tester.pumpWidget(
|
||||||
|
createTestWidget(feedBlogPostModel: existingPost),
|
||||||
|
);
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
expect(find.text('Ссылка на видео'), findsNothing);
|
await tester.enterText(
|
||||||
expect(find.byKey(AppKeys.blogPostCreateScreenVideoLinkTextField), findsNothing);
|
find.byKey(AppKeys.blogPostCreateScreenVideoLinkTextField),
|
||||||
|
'https://youtube.com/watch?v=abc',
|
||||||
|
);
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
final saveButton = find.text('Сохранить');
|
||||||
|
await tester.ensureVisible(saveButton);
|
||||||
|
await tester.tap(saveButton);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
verifyNever(
|
||||||
|
() => mockBlogPostRepository.updateBlogPost(
|
||||||
|
id: any(named: 'id'),
|
||||||
|
title: any(named: 'title'),
|
||||||
|
videoUrl: any(named: 'videoUrl'),
|
||||||
|
description: any(named: 'description'),
|
||||||
|
existingMediaIds: any(named: 'existingMediaIds'),
|
||||||
|
newImages: any(named: 'newImages'),
|
||||||
|
mediaIdsToDelete: any(named: 'mediaIdsToDelete'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
find.text('Поддерживаются только Rutube, VK и OK'),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('hides image field when video URL is present', (tester) async {
|
testWidgets('does not submit update when form content is cleared', (
|
||||||
final existingPost = FeedBlogPostModel(
|
tester,
|
||||||
id: 1,
|
) async {
|
||||||
title: 'Test Title',
|
|
||||||
description: 'Test Description',
|
|
||||||
videoUrl: 'https://youtube.com/watch?v=test',
|
|
||||||
image: '',
|
|
||||||
state: 'published',
|
|
||||||
author: const Author(authorSlug: 'test-author', authorName: 'Test Author', authorProfileIcon: 'avatar.jpg', authorIsMe: true),
|
|
||||||
createdAt: DateTime.now(),
|
|
||||||
updatedAt: DateTime.now(),
|
|
||||||
totalLikes: 0,
|
|
||||||
totalViews: 0,
|
|
||||||
totalComments: 0,
|
|
||||||
wasLikedByMe: false,
|
|
||||||
);
|
|
||||||
|
|
||||||
await tester.pumpWidget(createTestWidget(feedBlogPostModel: existingPost));
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
|
|
||||||
expect(find.text('Изображение поста'), findsNothing);
|
|
||||||
});
|
|
||||||
|
|
||||||
testWidgets('allows entering text in title field', (tester) async {
|
|
||||||
await tester.pumpWidget(createTestWidget());
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
|
|
||||||
await tester.enterText(find.byKey(AppKeys.blogPostCreateScreenTitleTextField), 'New Title');
|
|
||||||
await tester.pump();
|
|
||||||
|
|
||||||
final titleField = tester.widget<CustomTextFormField>(find.byKey(AppKeys.blogPostCreateScreenTitleTextField));
|
|
||||||
expect(titleField.controller?.text, 'New Title');
|
|
||||||
});
|
|
||||||
|
|
||||||
testWidgets('allows entering text in description field', (tester) async {
|
|
||||||
await tester.pumpWidget(createTestWidget());
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
|
|
||||||
await tester.enterText(find.byKey(AppKeys.blogPostCreateScreenContentTextField), 'New Description');
|
|
||||||
await tester.pump();
|
|
||||||
|
|
||||||
final descriptionField = tester.widget<CustomTextFormField>(find.byKey(AppKeys.blogPostCreateScreenContentTextField));
|
|
||||||
expect(descriptionField.controller?.text, 'New Description');
|
|
||||||
});
|
|
||||||
|
|
||||||
testWidgets('allows entering text in video URL field', (tester) async {
|
|
||||||
await tester.pumpWidget(createTestWidget());
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
|
|
||||||
await tester.enterText(find.byKey(AppKeys.blogPostCreateScreenVideoLinkTextField), 'https://youtube.com/watch?v=new');
|
|
||||||
await tester.pump();
|
|
||||||
|
|
||||||
final videoField = tester.widget<CustomTextFormField>(find.byKey(AppKeys.blogPostCreateScreenVideoLinkTextField));
|
|
||||||
expect(videoField.controller?.text, 'https://youtube.com/watch?v=new');
|
|
||||||
});
|
|
||||||
|
|
||||||
testWidgets('displays create button', (tester) async {
|
|
||||||
await tester.pumpWidget(createTestWidget());
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
|
|
||||||
expect(find.text('Создать'), findsOneWidget);
|
|
||||||
});
|
|
||||||
|
|
||||||
testWidgets('shows loading state when creating blog post', (tester) async {
|
|
||||||
when(mockBlogPostRepository.createBlogPost(any, any, any, any)).thenAnswer((_) async {
|
|
||||||
await Future.delayed(const Duration(milliseconds: 100));
|
|
||||||
return;
|
|
||||||
});
|
|
||||||
|
|
||||||
await tester.pumpWidget(createTestWidget());
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
|
|
||||||
await tester.enterText(find.byKey(AppKeys.blogPostCreateScreenTitleTextField), 'Test Title');
|
|
||||||
await tester.enterText(find.byKey(AppKeys.blogPostCreateScreenContentTextField), 'Test Description');
|
|
||||||
await tester.pump();
|
|
||||||
|
|
||||||
final createButton = find.text('Создать');
|
|
||||||
await tester.ensureVisible(createButton);
|
|
||||||
await tester.tap(createButton);
|
|
||||||
await tester.pump();
|
|
||||||
|
|
||||||
expect(find.byType(Loader), findsWidgets);
|
|
||||||
|
|
||||||
await tester.pump(const Duration(milliseconds: 150));
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
});
|
|
||||||
|
|
||||||
testWidgets('calls FeedCubit.loadFeedBlogPostList when blog post is created successfully', (tester) async {
|
|
||||||
when(mockBlogPostRepository.createBlogPost(any, any, any, any)).thenAnswer((_) async {
|
|
||||||
return;
|
|
||||||
});
|
|
||||||
|
|
||||||
await tester.pumpWidget(createTestWidget());
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
|
|
||||||
final titleField = find.byKey(AppKeys.blogPostCreateScreenTitleTextField);
|
|
||||||
final contentField = find.byKey(AppKeys.blogPostCreateScreenContentTextField);
|
|
||||||
|
|
||||||
await tester.ensureVisible(titleField);
|
|
||||||
await tester.ensureVisible(contentField);
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
|
|
||||||
await tester.enterText(titleField, 'Test Title');
|
|
||||||
await tester.enterText(contentField, 'Test Description');
|
|
||||||
await tester.pump();
|
|
||||||
|
|
||||||
final createButton = find.text('Создать');
|
|
||||||
await tester.ensureVisible(createButton);
|
|
||||||
await tester.tap(createButton, warnIfMissed: false);
|
|
||||||
await tester.pump();
|
|
||||||
|
|
||||||
await tester.pump(const Duration(milliseconds: 200));
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
|
|
||||||
verify(mockFeedCubit.loadFeedBlogPostList()).called(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
testWidgets('calls updateBlogPost when editing existing post', (tester) async {
|
|
||||||
await mockNetworkImagesFor(() async {
|
await mockNetworkImagesFor(() async {
|
||||||
when(mockBlogPostRepository.updateBlogPost(any, any, any, any, any, any)).thenAnswer((_) async {
|
when(
|
||||||
|
() => mockBlogPostRepository.updateBlogPost(
|
||||||
|
id: any(named: 'id'),
|
||||||
|
title: any(named: 'title'),
|
||||||
|
videoUrl: any(named: 'videoUrl'),
|
||||||
|
description: any(named: 'description'),
|
||||||
|
existingMediaIds: any(named: 'existingMediaIds'),
|
||||||
|
newImages: any(named: 'newImages'),
|
||||||
|
mediaIdsToDelete: any(named: 'mediaIdsToDelete'),
|
||||||
|
),
|
||||||
|
).thenAnswer((_) async {});
|
||||||
|
|
||||||
|
final existingPost = FeedBlogPostModel(
|
||||||
|
id: 1,
|
||||||
|
title: 'Test Title',
|
||||||
|
description: 'Test Description',
|
||||||
|
videoUrl: '',
|
||||||
|
image: '',
|
||||||
|
state: 'published',
|
||||||
|
author: const Author(
|
||||||
|
authorSlug: 'test-author',
|
||||||
|
authorName: 'Test Author',
|
||||||
|
authorProfileIcon: 'http://localhost/storage/avatar.jpg',
|
||||||
|
authorIsMe: true,
|
||||||
|
),
|
||||||
|
createdAt: DateTime.now(),
|
||||||
|
updatedAt: DateTime.now(),
|
||||||
|
totalLikes: 0,
|
||||||
|
totalViews: 0,
|
||||||
|
totalComments: 0,
|
||||||
|
wasLikedByMe: false,
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
createTestWidget(feedBlogPostModel: existingPost),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.enterText(
|
||||||
|
find.byKey(AppKeys.blogPostCreateScreenTitleTextField),
|
||||||
|
'',
|
||||||
|
);
|
||||||
|
await tester.enterText(
|
||||||
|
find.byKey(AppKeys.blogPostCreateScreenContentTextField),
|
||||||
|
'',
|
||||||
|
);
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
final saveButton = find.text('Сохранить');
|
||||||
|
await tester.ensureVisible(saveButton);
|
||||||
|
await tester.tap(saveButton);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
verifyNever(
|
||||||
|
() => mockBlogPostRepository.updateBlogPost(
|
||||||
|
id: any(named: 'id'),
|
||||||
|
title: any(named: 'title'),
|
||||||
|
videoUrl: any(named: 'videoUrl'),
|
||||||
|
description: any(named: 'description'),
|
||||||
|
existingMediaIds: any(named: 'existingMediaIds'),
|
||||||
|
newImages: any(named: 'newImages'),
|
||||||
|
mediaIdsToDelete: any(named: 'mediaIdsToDelete'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
find.text(
|
||||||
|
'Добавьте заголовок, описание, изображение или ссылку на видео',
|
||||||
|
),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('calls updateBlogPost when editing existing post', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
await mockNetworkImagesFor(() async {
|
||||||
|
when(
|
||||||
|
() => mockBlogPostRepository.updateBlogPost(
|
||||||
|
id: any(named: 'id'),
|
||||||
|
title: any(named: 'title'),
|
||||||
|
videoUrl: any(named: 'videoUrl'),
|
||||||
|
description: any(named: 'description'),
|
||||||
|
existingMediaIds: any(named: 'existingMediaIds'),
|
||||||
|
newImages: any(named: 'newImages'),
|
||||||
|
mediaIdsToDelete: any(named: 'mediaIdsToDelete'),
|
||||||
|
),
|
||||||
|
).thenAnswer((_) async {
|
||||||
return;
|
return;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -306,26 +620,46 @@ void main() {
|
|||||||
totalViews: 0,
|
totalViews: 0,
|
||||||
totalComments: 0,
|
totalComments: 0,
|
||||||
wasLikedByMe: false,
|
wasLikedByMe: false,
|
||||||
|
images: const [
|
||||||
|
FeedGalleryImage(
|
||||||
|
id: 10,
|
||||||
|
url: 'http://localhost/storage/test_image.jpg',
|
||||||
|
),
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
await tester.pumpWidget(createTestWidget(feedBlogPostModel: existingPost, canPop: true));
|
await tester.pumpWidget(
|
||||||
|
createTestWidget(feedBlogPostModel: existingPost, canPop: true),
|
||||||
|
);
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
final titleField = find.byKey(AppKeys.blogPostCreateScreenTitleTextField);
|
final titleField = find.byKey(
|
||||||
|
AppKeys.blogPostCreateScreenTitleTextField,
|
||||||
|
);
|
||||||
expect(titleField, findsOneWidget);
|
expect(titleField, findsOneWidget);
|
||||||
|
|
||||||
await tester.enterText(titleField, 'Updated Title');
|
await tester.enterText(titleField, 'Updated Title');
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
|
|
||||||
final createButton = find.text('Создать');
|
final saveButton = find.text('Сохранить');
|
||||||
await tester.ensureVisible(createButton);
|
await tester.ensureVisible(saveButton);
|
||||||
await tester.tap(createButton, warnIfMissed: false);
|
await tester.tap(saveButton, warnIfMissed: false);
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
|
|
||||||
await tester.pump(const Duration(milliseconds: 200));
|
await tester.pump(const Duration(milliseconds: 200));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
verify(mockBlogPostRepository.updateBlogPost(any, any, any, any, any, any)).called(1);
|
verify(
|
||||||
|
() => mockBlogPostRepository.updateBlogPost(
|
||||||
|
id: any(named: 'id'),
|
||||||
|
title: any(named: 'title'),
|
||||||
|
videoUrl: any(named: 'videoUrl'),
|
||||||
|
description: any(named: 'description'),
|
||||||
|
existingMediaIds: any(named: 'existingMediaIds'),
|
||||||
|
newImages: any(named: 'newImages'),
|
||||||
|
mediaIdsToDelete: any(named: 'mediaIdsToDelete'),
|
||||||
|
),
|
||||||
|
).called(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -344,8 +678,9 @@ void main() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('renders error state', (tester) async {
|
testWidgets('renders error state', (tester) async {
|
||||||
|
blogPostCreateCubit.emit(
|
||||||
blogPostCreateCubit.emit(const BlogPostCreateState.error(errorMessage: 'Error creating post'));
|
const BlogPostCreateState(errorMessage: 'Error creating post'),
|
||||||
|
);
|
||||||
|
|
||||||
await tester.pumpWidget(createTestWidget());
|
await tester.pumpWidget(createTestWidget());
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
@@ -354,7 +689,7 @@ void main() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('renders loading state', (tester) async {
|
testWidgets('renders loading state', (tester) async {
|
||||||
blogPostCreateCubit.emit(const BlogPostCreateState.loading());
|
blogPostCreateCubit.emit(const BlogPostCreateState(isLoading: true));
|
||||||
|
|
||||||
await tester.pumpWidget(createTestWidget());
|
await tester.pumpWidget(createTestWidget());
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|||||||
@@ -25,204 +25,172 @@ void main() {
|
|||||||
test('deleteBlogPost вызывает сервис', () async {
|
test('deleteBlogPost вызывает сервис', () async {
|
||||||
const id = 1;
|
const id = 1;
|
||||||
|
|
||||||
when(() => mockService.deleteBlogPost(id: id)).thenAnswer((_) async => {});
|
when(
|
||||||
|
() => mockService.deleteBlogPost(id: id),
|
||||||
|
).thenAnswer((_) async => {});
|
||||||
|
|
||||||
await repository.deleteBlogPost(id);
|
await repository.deleteBlogPost(id);
|
||||||
|
|
||||||
verify(() => mockService.deleteBlogPost(id: id)).called(1);
|
verify(() => mockService.deleteBlogPost(id: id)).called(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('deleteBlogPostMedia вызывает сервис', () async {
|
||||||
|
when(
|
||||||
|
() => mockService.deleteBlogPostMedia(postId: 10, mediaId: 20),
|
||||||
|
).thenAnswer((_) async => {});
|
||||||
|
|
||||||
|
await repository.deleteBlogPostMedia(postId: 10, mediaId: 20);
|
||||||
|
|
||||||
|
verify(
|
||||||
|
() => mockService.deleteBlogPostMedia(postId: 10, mediaId: 20),
|
||||||
|
).called(1);
|
||||||
|
});
|
||||||
|
|
||||||
test('createBlogPost вызывает сервис с FormData', () async {
|
test('createBlogPost вызывает сервис с FormData', () async {
|
||||||
const title = 'Test Title';
|
|
||||||
const description = 'Test Description';
|
|
||||||
|
|
||||||
when(() => mockService.createBlogPost(formData: any(named: 'formData'))).thenAnswer((_) async => {});
|
|
||||||
|
|
||||||
await repository.createBlogPost(title, null, description, null);
|
|
||||||
|
|
||||||
verify(() => mockService.createBlogPost(formData: any(named: 'formData'))).called(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('updateBlogPost вызывает сервис с FormData', () async {
|
|
||||||
const id = 1;
|
|
||||||
const title = 'Updated Title';
|
|
||||||
|
|
||||||
when(
|
when(
|
||||||
() => mockService.updateBlogPost(
|
() => mockService.createBlogPost(formData: any(named: 'formData')),
|
||||||
id: id,
|
|
||||||
formData: any(named: 'formData'),
|
|
||||||
),
|
|
||||||
).thenAnswer((_) async => {});
|
).thenAnswer((_) async => {});
|
||||||
|
|
||||||
await repository.updateBlogPost(id, title, null, null, null, null);
|
await repository.createBlogPost(
|
||||||
|
title: 'Test Title',
|
||||||
|
description: 'Test Description',
|
||||||
|
);
|
||||||
|
|
||||||
verify(
|
final captured =
|
||||||
() => mockService.updateBlogPost(
|
verify(
|
||||||
id: id,
|
() => mockService.createBlogPost(
|
||||||
formData: any(named: 'formData'),
|
formData: captureAny(named: 'formData'),
|
||||||
),
|
),
|
||||||
).called(1);
|
).captured.single
|
||||||
});
|
as FormData;
|
||||||
|
|
||||||
test('createBlogPost - обрабатывает пустые поля', () async {
|
expect(
|
||||||
when(() => mockService.createBlogPost(formData: any(named: 'formData'))).thenAnswer((_) async => {});
|
captured.fields.map((e) => '${e.key}=${e.value}'),
|
||||||
|
containsAll(['title=Test Title', 'description_md=Test Description']),
|
||||||
await repository.createBlogPost(null, null, null, null);
|
);
|
||||||
|
expect(captured.files, isEmpty);
|
||||||
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 {
|
test('createBlogPost - обрабатывает только videoUrl', () async {
|
||||||
const videoUrl = 'https://example.com/video.mp4';
|
const videoUrl = 'https://rutube.ru/video/12345/';
|
||||||
|
|
||||||
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(
|
when(
|
||||||
() => mockService.updateBlogPost(
|
() => mockService.createBlogPost(formData: any(named: 'formData')),
|
||||||
id: id,
|
|
||||||
formData: any(named: 'formData'),
|
|
||||||
),
|
|
||||||
).thenAnswer((_) async => {});
|
).thenAnswer((_) async => {});
|
||||||
|
|
||||||
await repository.updateBlogPost(id, title, videoUrl, description, null, null);
|
await repository.createBlogPost(videoUrl: videoUrl);
|
||||||
|
|
||||||
verify(
|
final captured =
|
||||||
() => mockService.updateBlogPost(
|
verify(
|
||||||
id: id,
|
() => mockService.createBlogPost(
|
||||||
formData: any(named: 'formData'),
|
formData: captureAny(named: 'formData'),
|
||||||
),
|
),
|
||||||
).called(1);
|
).captured.single
|
||||||
|
as FormData;
|
||||||
|
|
||||||
|
expect(
|
||||||
|
captured.fields.map((e) => '${e.key}=${e.value}'),
|
||||||
|
contains('video_url=$videoUrl'),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
'updateBlogPost отправляет existing_media_ids и удаляет media пачкой',
|
||||||
|
() async {
|
||||||
|
when(
|
||||||
|
() => mockService.deleteBlogPostMedia(
|
||||||
|
postId: 1,
|
||||||
|
mediaId: any(named: 'mediaId'),
|
||||||
|
),
|
||||||
|
).thenAnswer((_) async => {});
|
||||||
|
when(
|
||||||
|
() => mockService.updateBlogPost(
|
||||||
|
id: 1,
|
||||||
|
formData: any(named: 'formData'),
|
||||||
|
),
|
||||||
|
).thenAnswer((_) async => {});
|
||||||
|
|
||||||
|
await repository.updateBlogPost(
|
||||||
|
id: 1,
|
||||||
|
title: 'Updated Title',
|
||||||
|
existingMediaIds: [5, 3],
|
||||||
|
mediaIdsToDelete: [9, 8],
|
||||||
|
);
|
||||||
|
|
||||||
|
verify(
|
||||||
|
() => mockService.deleteBlogPostMedia(postId: 1, mediaId: 9),
|
||||||
|
).called(1);
|
||||||
|
verify(
|
||||||
|
() => mockService.deleteBlogPostMedia(postId: 1, mediaId: 8),
|
||||||
|
).called(1);
|
||||||
|
|
||||||
|
final captured =
|
||||||
|
verify(
|
||||||
|
() => mockService.updateBlogPost(
|
||||||
|
id: 1,
|
||||||
|
formData: captureAny(named: 'formData'),
|
||||||
|
),
|
||||||
|
).captured.single
|
||||||
|
as FormData;
|
||||||
|
|
||||||
|
final fields = captured.fields
|
||||||
|
.map((e) => '${e.key}=${e.value}')
|
||||||
|
.toList();
|
||||||
|
expect(fields, contains('title=Updated Title'));
|
||||||
|
expect(fields, contains('description_md='));
|
||||||
|
expect(fields, contains('video_url='));
|
||||||
|
expect(fields, contains('existing_media_ids[]=5'));
|
||||||
|
expect(fields, contains('existing_media_ids[]=3'));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
'updateBlogPost отправляет пустые text fields для очистки на бэке',
|
||||||
|
() async {
|
||||||
|
when(
|
||||||
|
() => mockService.updateBlogPost(
|
||||||
|
id: 1,
|
||||||
|
formData: any(named: 'formData'),
|
||||||
|
),
|
||||||
|
).thenAnswer((_) async => {});
|
||||||
|
|
||||||
|
await repository.updateBlogPost(
|
||||||
|
id: 1,
|
||||||
|
title: '',
|
||||||
|
description: '',
|
||||||
|
videoUrl: '',
|
||||||
|
existingMediaIds: const [],
|
||||||
|
);
|
||||||
|
|
||||||
|
final captured =
|
||||||
|
verify(
|
||||||
|
() => mockService.updateBlogPost(
|
||||||
|
id: 1,
|
||||||
|
formData: captureAny(named: 'formData'),
|
||||||
|
),
|
||||||
|
).captured.single
|
||||||
|
as FormData;
|
||||||
|
|
||||||
|
final fields = captured.fields
|
||||||
|
.map((e) => '${e.key}=${e.value}')
|
||||||
|
.toList();
|
||||||
|
expect(fields, contains('title='));
|
||||||
|
expect(fields, contains('description_md='));
|
||||||
|
expect(fields, contains('video_url='));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
test('updateBlogPost - обрабатывает пустые поля', () async {
|
test('updateBlogPost - обрабатывает пустые поля', () async {
|
||||||
const id = 1;
|
|
||||||
|
|
||||||
when(
|
when(
|
||||||
() => mockService.updateBlogPost(
|
() =>
|
||||||
id: id,
|
mockService.updateBlogPost(id: 1, formData: any(named: 'formData')),
|
||||||
formData: any(named: 'formData'),
|
|
||||||
),
|
|
||||||
).thenAnswer((_) async => {});
|
).thenAnswer((_) async => {});
|
||||||
|
|
||||||
await repository.updateBlogPost(id, null, null, null, null, null);
|
await repository.updateBlogPost(id: 1);
|
||||||
|
|
||||||
verify(
|
verify(
|
||||||
() => mockService.updateBlogPost(
|
() =>
|
||||||
id: id,
|
mockService.updateBlogPost(id: 1, formData: any(named: 'formData')),
|
||||||
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);
|
).called(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -18,8 +18,9 @@ void main() {
|
|||||||
test('createBlogPost - создает пост блога', () async {
|
test('createBlogPost - создает пост блога', () async {
|
||||||
final formData = FormData.fromMap({});
|
final formData = FormData.fromMap({});
|
||||||
|
|
||||||
when(mockBlogPostService.createBlogPost(formData: formData))
|
when(
|
||||||
.thenAnswer((_) async => Future.value());
|
mockBlogPostService.createBlogPost(formData: formData),
|
||||||
|
).thenAnswer((_) async => Future.value());
|
||||||
|
|
||||||
await mockBlogPostService.createBlogPost(formData: formData);
|
await mockBlogPostService.createBlogPost(formData: formData);
|
||||||
|
|
||||||
@@ -30,19 +31,35 @@ void main() {
|
|||||||
const id = 1;
|
const id = 1;
|
||||||
final formData = FormData.fromMap({});
|
final formData = FormData.fromMap({});
|
||||||
|
|
||||||
when(mockBlogPostService.updateBlogPost(id: id, formData: formData))
|
when(
|
||||||
.thenAnswer((_) async => Future.value());
|
mockBlogPostService.updateBlogPost(id: id, formData: formData),
|
||||||
|
).thenAnswer((_) async => Future.value());
|
||||||
|
|
||||||
await mockBlogPostService.updateBlogPost(id: id, formData: formData);
|
await mockBlogPostService.updateBlogPost(id: id, formData: formData);
|
||||||
|
|
||||||
verify(mockBlogPostService.updateBlogPost(id: id, formData: formData)).called(1);
|
verify(
|
||||||
|
mockBlogPostService.updateBlogPost(id: id, formData: formData),
|
||||||
|
).called(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('deleteBlogPostMedia - удаляет изображение поста', () async {
|
||||||
|
when(
|
||||||
|
mockBlogPostService.deleteBlogPostMedia(postId: 1, mediaId: 2),
|
||||||
|
).thenAnswer((_) async => Future.value());
|
||||||
|
|
||||||
|
await mockBlogPostService.deleteBlogPostMedia(postId: 1, mediaId: 2);
|
||||||
|
|
||||||
|
verify(
|
||||||
|
mockBlogPostService.deleteBlogPostMedia(postId: 1, mediaId: 2),
|
||||||
|
).called(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('deleteBlogPost - удаляет пост блога', () async {
|
test('deleteBlogPost - удаляет пост блога', () async {
|
||||||
const id = 1;
|
const id = 1;
|
||||||
|
|
||||||
when(mockBlogPostService.deleteBlogPost(id: id))
|
when(
|
||||||
.thenAnswer((_) async => Future.value());
|
mockBlogPostService.deleteBlogPost(id: id),
|
||||||
|
).thenAnswer((_) async => Future.value());
|
||||||
|
|
||||||
await mockBlogPostService.deleteBlogPost(id: id);
|
await mockBlogPostService.deleteBlogPost(id: id);
|
||||||
|
|
||||||
@@ -50,4 +67,3 @@ void main() {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,191 @@
|
|||||||
|
import 'package:eatvio_app/features/blog_post_create/validation/blog_post_form_validator.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('BlogPostFormValidator.title', () {
|
||||||
|
test('allows empty', () {
|
||||||
|
expect(BlogPostFormValidator.title(''), isNull);
|
||||||
|
expect(BlogPostFormValidator.title(' '), isNull);
|
||||||
|
expect(BlogPostFormValidator.title(null), isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects shorter than 3 chars', () {
|
||||||
|
expect(
|
||||||
|
BlogPostFormValidator.title('ab'),
|
||||||
|
BlogPostFormValidator.titleTooShort,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
BlogPostFormValidator.title(' a '),
|
||||||
|
BlogPostFormValidator.titleTooShort,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('allows 3+ chars', () {
|
||||||
|
expect(BlogPostFormValidator.title('abc'), isNull);
|
||||||
|
expect(BlogPostFormValidator.title(' hello '), isNull);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('BlogPostFormValidator.description', () {
|
||||||
|
test('allows empty and rejects short', () {
|
||||||
|
expect(BlogPostFormValidator.description(''), isNull);
|
||||||
|
expect(
|
||||||
|
BlogPostFormValidator.description('xy'),
|
||||||
|
BlogPostFormValidator.descriptionTooShort,
|
||||||
|
);
|
||||||
|
expect(BlogPostFormValidator.description('xyz'), isNull);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('BlogPostFormValidator.videoUrl', () {
|
||||||
|
test('allows empty', () {
|
||||||
|
expect(BlogPostFormValidator.videoUrl(''), isNull);
|
||||||
|
expect(BlogPostFormValidator.videoUrl(' '), isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects unsupported hosts', () {
|
||||||
|
expect(
|
||||||
|
BlogPostFormValidator.videoUrl('https://youtube.com/watch?v=abc'),
|
||||||
|
BlogPostFormValidator.videoUrlUnsupportedHost,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
BlogPostFormValidator.videoUrl('https://rutube.ru.evil.com/video/abc'),
|
||||||
|
BlogPostFormValidator.videoUrlUnsupportedHost,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('accepts rutube / vk / ok formats', () {
|
||||||
|
expect(
|
||||||
|
BlogPostFormValidator.videoUrl('https://rutube.ru/video/abc123/'),
|
||||||
|
isNull,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
BlogPostFormValidator.videoUrl('https://www.rutube.ru/shorts/abc123'),
|
||||||
|
isNull,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
BlogPostFormValidator.videoUrl('https://vk.com/video-123_456'),
|
||||||
|
isNull,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
BlogPostFormValidator.videoUrl('https://vkvideo.ru/video123_456/'),
|
||||||
|
isNull,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
BlogPostFormValidator.videoUrl('https://ok.ru/video/12345'),
|
||||||
|
isNull,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects invalid path formats for allowed hosts', () {
|
||||||
|
expect(
|
||||||
|
BlogPostFormValidator.videoUrl('https://rutube.ru/channel/abc'),
|
||||||
|
BlogPostFormValidator.videoUrlRutubeFormat,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
BlogPostFormValidator.videoUrl('https://vk.com/feed'),
|
||||||
|
BlogPostFormValidator.videoUrlVkFormat,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
BlogPostFormValidator.videoUrl('https://ok.ru/profile/1'),
|
||||||
|
BlogPostFormValidator.videoUrlOkFormat,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects malformed urls', () {
|
||||||
|
expect(
|
||||||
|
BlogPostFormValidator.videoUrl('not-a-url'),
|
||||||
|
BlogPostFormValidator.videoUrlInvalid,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
BlogPostFormValidator.videoUrl('ftp://rutube.ru/video/abc'),
|
||||||
|
BlogPostFormValidator.videoUrlInvalid,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('BlogPostFormValidator.contentRequiredMessage', () {
|
||||||
|
test('requires at least one filled field', () {
|
||||||
|
expect(
|
||||||
|
BlogPostFormValidator.contentRequiredMessage(
|
||||||
|
title: '',
|
||||||
|
description: '',
|
||||||
|
videoUrl: '',
|
||||||
|
imageCount: 0,
|
||||||
|
),
|
||||||
|
BlogPostFormValidator.contentRequired,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('accepts any valid content source', () {
|
||||||
|
expect(
|
||||||
|
BlogPostFormValidator.contentRequiredMessage(
|
||||||
|
title: 'abc',
|
||||||
|
description: '',
|
||||||
|
videoUrl: '',
|
||||||
|
imageCount: 0,
|
||||||
|
),
|
||||||
|
isNull,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
BlogPostFormValidator.contentRequiredMessage(
|
||||||
|
title: '',
|
||||||
|
description: 'abc',
|
||||||
|
videoUrl: '',
|
||||||
|
imageCount: 0,
|
||||||
|
),
|
||||||
|
isNull,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
BlogPostFormValidator.contentRequiredMessage(
|
||||||
|
title: '',
|
||||||
|
description: '',
|
||||||
|
videoUrl: '',
|
||||||
|
imageCount: 1,
|
||||||
|
),
|
||||||
|
isNull,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
BlogPostFormValidator.contentRequiredMessage(
|
||||||
|
title: '',
|
||||||
|
description: '',
|
||||||
|
videoUrl: 'https://rutube.ru/video/abc123/',
|
||||||
|
imageCount: 0,
|
||||||
|
),
|
||||||
|
isNull,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('does not count short title or invalid video as content', () {
|
||||||
|
expect(
|
||||||
|
BlogPostFormValidator.contentRequiredMessage(
|
||||||
|
title: 'ab',
|
||||||
|
description: '',
|
||||||
|
videoUrl: 'https://youtube.com/watch?v=1',
|
||||||
|
imageCount: 0,
|
||||||
|
),
|
||||||
|
BlogPostFormValidator.contentRequired,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('BlogPostFormValidator.imagePaths', () {
|
||||||
|
test('allows jpg/jpeg/png', () {
|
||||||
|
expect(
|
||||||
|
BlogPostFormValidator.imagePaths(['a.jpg', 'b.JPEG', 'c.png']),
|
||||||
|
isNull,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects other extensions', () {
|
||||||
|
expect(
|
||||||
|
BlogPostFormValidator.imagePaths(['a.webp']),
|
||||||
|
BlogPostFormValidator.imageTypeInvalid,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
BlogPostFormValidator.imagePaths(['noext']),
|
||||||
|
BlogPostFormValidator.imageTypeInvalid,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -200,7 +200,7 @@ void main() {
|
|||||||
|
|
||||||
test('markMessagesAsRead - отмечает сообщения как прочитанные', () async {
|
test('markMessagesAsRead - отмечает сообщения как прочитанные', () async {
|
||||||
const chatId = 'chat-id';
|
const chatId = 'chat-id';
|
||||||
final messagesIds = [(id: 1, index: 0)];
|
final messagesIds = [1];
|
||||||
|
|
||||||
// Сначала инициализируем чат
|
// Сначала инициализируем чат
|
||||||
when(mockChatRepository.getChatRoom(any)).thenAnswer((_) async => testChatRoom);
|
when(mockChatRepository.getChatRoom(any)).thenAnswer((_) async => testChatRoom);
|
||||||
@@ -1443,5 +1443,218 @@ void main() {
|
|||||||
|
|
||||||
verifyNever(mockChatRepository.getChatMessages(any, any));
|
verifyNever(mockChatRepository.getChatMessages(any, any));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('addIncomingMessage - добавляет входящее сообщение в начало списка', () async {
|
||||||
|
const uuid = 'room-uuid';
|
||||||
|
when(mockChatRepository.getChatRoom(uuid)).thenAnswer((_) async => testChatRoom);
|
||||||
|
when(mockChatRepository.getChatMessages(uuid, 1)).thenAnswer((_) async => testChatMessages);
|
||||||
|
when(mockWsManager.client).thenReturn(null);
|
||||||
|
when(mockWsManager.initPrivateDirectChatChannel(any)).thenAnswer((_) async => Future.value());
|
||||||
|
when(mockWsManager.subscribeDirectChatChannel()).thenReturn(null);
|
||||||
|
when(mockWsManager.directChatStream).thenReturn(null);
|
||||||
|
when(mockWsManager.clientTypingStream).thenReturn(null);
|
||||||
|
when(mockChatRepository.getChosenPlan(id: anyNamed('id'))).thenAnswer((_) async => throw Exception('No plan'));
|
||||||
|
|
||||||
|
await chatRoomCubit.initChat(uuid);
|
||||||
|
await Future.delayed(const Duration(milliseconds: 200));
|
||||||
|
|
||||||
|
final incoming = ChatMessageModel(
|
||||||
|
id: 99,
|
||||||
|
data: 'Incoming socket message',
|
||||||
|
mediaData: null,
|
||||||
|
authorId: 2,
|
||||||
|
updatedAt: DateTime.now(),
|
||||||
|
isRead: false,
|
||||||
|
isCurrentUserAuthor: false,
|
||||||
|
roomId: 'chat-id',
|
||||||
|
);
|
||||||
|
|
||||||
|
chatRoomCubit.addIncomingMessage(incoming);
|
||||||
|
|
||||||
|
final state = chatRoomCubit.state as ChatRoomStateLoaded;
|
||||||
|
expect(state.chatMessages.first.id, 99);
|
||||||
|
expect(state.chatMessages.length, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('addIncomingMessage - не добавляет дубликат по id', () async {
|
||||||
|
const uuid = 'room-uuid';
|
||||||
|
when(mockChatRepository.getChatRoom(uuid)).thenAnswer((_) async => testChatRoom);
|
||||||
|
when(mockChatRepository.getChatMessages(uuid, 1)).thenAnswer((_) async => testChatMessages);
|
||||||
|
when(mockWsManager.client).thenReturn(null);
|
||||||
|
when(mockWsManager.initPrivateDirectChatChannel(any)).thenAnswer((_) async => Future.value());
|
||||||
|
when(mockWsManager.subscribeDirectChatChannel()).thenReturn(null);
|
||||||
|
when(mockWsManager.directChatStream).thenReturn(null);
|
||||||
|
when(mockWsManager.clientTypingStream).thenReturn(null);
|
||||||
|
when(mockChatRepository.getChosenPlan(id: anyNamed('id'))).thenAnswer((_) async => throw Exception('No plan'));
|
||||||
|
|
||||||
|
await chatRoomCubit.initChat(uuid);
|
||||||
|
await Future.delayed(const Duration(milliseconds: 200));
|
||||||
|
|
||||||
|
final incoming = ChatMessageModel(
|
||||||
|
id: 1,
|
||||||
|
data: 'Duplicate of existing',
|
||||||
|
mediaData: null,
|
||||||
|
authorId: 2,
|
||||||
|
updatedAt: DateTime.now(),
|
||||||
|
isRead: false,
|
||||||
|
isCurrentUserAuthor: false,
|
||||||
|
roomId: 'chat-id',
|
||||||
|
);
|
||||||
|
|
||||||
|
chatRoomCubit.addIncomingMessage(incoming);
|
||||||
|
|
||||||
|
final state = chatRoomCubit.state as ChatRoomStateLoaded;
|
||||||
|
expect(state.chatMessages.length, 1);
|
||||||
|
expect(state.chatMessages.first.data, 'Test message');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('addIncomingMessage - игнорирует собственные сообщения', () async {
|
||||||
|
const uuid = 'room-uuid';
|
||||||
|
when(mockChatRepository.getChatRoom(uuid)).thenAnswer((_) async => testChatRoom);
|
||||||
|
when(mockChatRepository.getChatMessages(uuid, 1)).thenAnswer((_) async => testChatMessages);
|
||||||
|
when(mockWsManager.client).thenReturn(null);
|
||||||
|
when(mockWsManager.userId).thenReturn(1);
|
||||||
|
when(mockWsManager.initPrivateDirectChatChannel(any)).thenAnswer((_) async => Future.value());
|
||||||
|
when(mockWsManager.subscribeDirectChatChannel()).thenReturn(null);
|
||||||
|
when(mockWsManager.directChatStream).thenReturn(null);
|
||||||
|
when(mockWsManager.clientTypingStream).thenReturn(null);
|
||||||
|
when(mockChatRepository.getChosenPlan(id: anyNamed('id'))).thenAnswer((_) async => throw Exception('No plan'));
|
||||||
|
|
||||||
|
await chatRoomCubit.initChat(uuid);
|
||||||
|
await Future.delayed(const Duration(milliseconds: 200));
|
||||||
|
|
||||||
|
chatRoomCubit.addIncomingMessage(
|
||||||
|
ChatMessageModel(
|
||||||
|
id: 77,
|
||||||
|
data: 'Own echo',
|
||||||
|
mediaData: null,
|
||||||
|
authorId: 1,
|
||||||
|
updatedAt: DateTime.now(),
|
||||||
|
isRead: true,
|
||||||
|
isCurrentUserAuthor: true,
|
||||||
|
roomId: 'chat-id',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final state = chatRoomCubit.state as ChatRoomStateLoaded;
|
||||||
|
expect(state.chatMessages.length, 1);
|
||||||
|
expect(state.chatMessages.any((message) => message.id == 77), isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('updateChosenPlanState - не затирает сообщения, пришедшие во время запроса', () async {
|
||||||
|
const uuid = 'room-uuid';
|
||||||
|
const planId = 123;
|
||||||
|
final chatRoomWithPlan = testChatRoom.copyWith(chosenPlanId: planId);
|
||||||
|
const testPlan = PlanModel(
|
||||||
|
id: 1,
|
||||||
|
slug: 'test-plan',
|
||||||
|
name: 'Test Plan',
|
||||||
|
image: 'https://example.com/image.jpg',
|
||||||
|
duration: 7,
|
||||||
|
state: 'active',
|
||||||
|
type: PlanType(fit: true, slim: false, behavior: false, wellness: false),
|
||||||
|
details: 'Test details',
|
||||||
|
packetsCount: 3,
|
||||||
|
author: Author(authorSlug: 'author', authorName: 'Author Name', authorProfileIcon: 'https://example.com/icon.jpg', authorIsMe: false),
|
||||||
|
totalLikes: 10,
|
||||||
|
totalViews: 100,
|
||||||
|
totalComments: 5,
|
||||||
|
isFavorite: false,
|
||||||
|
minPriceOfOptions: 1000,
|
||||||
|
maxPriceOfOptions: 2000,
|
||||||
|
wasLikedByMe: false,
|
||||||
|
);
|
||||||
|
final chosenPlan = ChosenPlanModel(
|
||||||
|
id: planId,
|
||||||
|
optionType: 'options1',
|
||||||
|
startDate: DateTime.now(),
|
||||||
|
chosenPlanState: ChosenPlanStateEnum.stateStarted,
|
||||||
|
chatId: 'chat-1',
|
||||||
|
consultantId: 1,
|
||||||
|
plan: testPlan,
|
||||||
|
);
|
||||||
|
final updatedPlan = chosenPlan.copyWith(chosenPlanState: ChosenPlanStateEnum.statePaid);
|
||||||
|
final planCompleter = Completer<ChosenPlanModel>();
|
||||||
|
var getChosenPlanCalls = 0;
|
||||||
|
|
||||||
|
when(mockChatRepository.getChatRoom(uuid)).thenAnswer((_) async => chatRoomWithPlan);
|
||||||
|
when(mockChatRepository.getChatMessages(uuid, 1)).thenAnswer((_) async => testChatMessages);
|
||||||
|
when(mockChatRepository.getChosenPlan(id: planId)).thenAnswer((_) {
|
||||||
|
getChosenPlanCalls++;
|
||||||
|
if (getChosenPlanCalls == 1) {
|
||||||
|
return Future.value(chosenPlan);
|
||||||
|
}
|
||||||
|
return planCompleter.future;
|
||||||
|
});
|
||||||
|
when(mockWsManager.client).thenReturn(null);
|
||||||
|
when(mockWsManager.initPrivateDirectChatChannel(any)).thenAnswer((_) async => Future.value());
|
||||||
|
when(mockWsManager.subscribeDirectChatChannel()).thenReturn(null);
|
||||||
|
when(mockWsManager.directChatStream).thenReturn(null);
|
||||||
|
when(mockWsManager.clientTypingStream).thenReturn(null);
|
||||||
|
|
||||||
|
await chatRoomCubit.initChat(uuid);
|
||||||
|
await Future.delayed(const Duration(milliseconds: 200));
|
||||||
|
|
||||||
|
final updateFuture = chatRoomCubit.updateChosenPlanState();
|
||||||
|
chatRoomCubit.addIncomingMessage(
|
||||||
|
ChatMessageModel(
|
||||||
|
id: 55,
|
||||||
|
data: 'Arrived during plan poll',
|
||||||
|
mediaData: null,
|
||||||
|
authorId: 2,
|
||||||
|
updatedAt: DateTime.now(),
|
||||||
|
isRead: false,
|
||||||
|
isCurrentUserAuthor: false,
|
||||||
|
roomId: 'chat-id',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
planCompleter.complete(updatedPlan);
|
||||||
|
await updateFuture;
|
||||||
|
|
||||||
|
final state = chatRoomCubit.state as ChatRoomStateLoaded;
|
||||||
|
expect(state.chatMessages.any((message) => message.id == 55), isTrue);
|
||||||
|
expect(state.chatRoom.chosenPlan?.chosenPlanState, ChosenPlanStateEnum.statePaid);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('markMessagesAsRead - не затирает сообщения, пришедшие во время запроса', () async {
|
||||||
|
const uuid = 'room-uuid';
|
||||||
|
const chatId = 'chat-id';
|
||||||
|
final markCompleter = Completer<void>();
|
||||||
|
|
||||||
|
when(mockChatRepository.getChatRoom(uuid)).thenAnswer((_) async => testChatRoom);
|
||||||
|
when(mockChatRepository.getChatMessages(uuid, 1)).thenAnswer((_) async => testChatMessages);
|
||||||
|
when(mockWsManager.client).thenReturn(null);
|
||||||
|
when(mockWsManager.initPrivateDirectChatChannel(any)).thenAnswer((_) async => Future.value());
|
||||||
|
when(mockWsManager.subscribeDirectChatChannel()).thenReturn(null);
|
||||||
|
when(mockWsManager.directChatStream).thenReturn(null);
|
||||||
|
when(mockWsManager.clientTypingStream).thenReturn(null);
|
||||||
|
when(mockChatRepository.getChosenPlan(id: anyNamed('id'))).thenAnswer((_) async => throw Exception('No plan'));
|
||||||
|
when(mockChatRepository.markMessagesAsRead(chatId, [1])).thenAnswer((_) => markCompleter.future);
|
||||||
|
|
||||||
|
await chatRoomCubit.initChat(uuid);
|
||||||
|
await Future.delayed(const Duration(milliseconds: 200));
|
||||||
|
|
||||||
|
final markFuture = chatRoomCubit.markMessagesAsRead(chatId, [1]);
|
||||||
|
chatRoomCubit.addIncomingMessage(
|
||||||
|
ChatMessageModel(
|
||||||
|
id: 88,
|
||||||
|
data: 'Arrived during mark as read',
|
||||||
|
mediaData: null,
|
||||||
|
authorId: 2,
|
||||||
|
updatedAt: DateTime.now(),
|
||||||
|
isRead: false,
|
||||||
|
isCurrentUserAuthor: false,
|
||||||
|
roomId: chatId,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
markCompleter.complete();
|
||||||
|
await markFuture;
|
||||||
|
|
||||||
|
final state = chatRoomCubit.state as ChatRoomStateLoaded;
|
||||||
|
expect(state.chatMessages.any((message) => message.id == 88), isTrue);
|
||||||
|
expect(state.chatMessages.firstWhere((message) => message.id == 1).isRead, isTrue);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,4 +33,3 @@ void main() {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -100,4 +100,3 @@ void main() {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -63,7 +63,10 @@ void main() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('toJson serializes all fields correctly', () {
|
test('toJson serializes all fields correctly', () {
|
||||||
const model = CertificatesModel(title: 'Certificate Title', date: '2024-01-15');
|
const model = CertificatesModel(
|
||||||
|
title: 'Certificate Title',
|
||||||
|
date: '2024-01-15',
|
||||||
|
);
|
||||||
|
|
||||||
final json = model.toJson();
|
final json = model.toJson();
|
||||||
|
|
||||||
@@ -105,8 +108,21 @@ void main() {
|
|||||||
|
|
||||||
test('toJson serializes all fields correctly', () {
|
test('toJson serializes all fields correctly', () {
|
||||||
const socialLinks = SocialLinksModel(vkLink: 'https://vk.com/user');
|
const socialLinks = SocialLinksModel(vkLink: 'https://vk.com/user');
|
||||||
const certificates = CertificatesModel(title: 'Course 1', date: '2024-01-01');
|
const certificates = CertificatesModel(
|
||||||
const model = ConsultantProfileModel(hasMedicalEducation: true, socialLinks: socialLinks, photos: ['photo1.jpg'], certificates: [certificates]);
|
title: 'Course 1',
|
||||||
|
date: '2024-01-01',
|
||||||
|
);
|
||||||
|
const model = ConsultantProfileModel(
|
||||||
|
hasMedicalEducation: true,
|
||||||
|
socialLinks: socialLinks,
|
||||||
|
photos: ['photo1.jpg'],
|
||||||
|
certificates: [certificates],
|
||||||
|
legalType: 'individualPerson',
|
||||||
|
inn: '123456789012',
|
||||||
|
snils: '123-456-789 00',
|
||||||
|
agreementAccepted: true,
|
||||||
|
frdoAgreementAccepted: true,
|
||||||
|
);
|
||||||
|
|
||||||
final json = model.toJson();
|
final json = model.toJson();
|
||||||
|
|
||||||
@@ -114,6 +130,38 @@ void main() {
|
|||||||
expect(json['social_links'], isNotNull);
|
expect(json['social_links'], isNotNull);
|
||||||
expect(json['cert_photos'], ['photo1.jpg']);
|
expect(json['cert_photos'], ['photo1.jpg']);
|
||||||
expect(json['completed_courses'], isNotNull);
|
expect(json['completed_courses'], isNotNull);
|
||||||
|
expect(json['legal_type'], 'individualPerson');
|
||||||
|
expect(json['inn'], '123456789012');
|
||||||
|
expect(json['snils'], '123-456-789 00');
|
||||||
|
expect(json['agreement_accepted'], isTrue);
|
||||||
|
expect(json['frdo_agreement_accepted'], isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('hasValidPassportData validates required individualPerson fields', () {
|
||||||
|
const valid = ConsultantProfileModel(
|
||||||
|
lastName: 'Иванов',
|
||||||
|
firstName: 'Иван',
|
||||||
|
inn: '123456789012',
|
||||||
|
snils: '123-456-789 00',
|
||||||
|
passportSeries: '1234',
|
||||||
|
passportNumber: '567890',
|
||||||
|
passportIssueDate: '2020-01-15',
|
||||||
|
passportIssuer: 'ОВД',
|
||||||
|
passportIssuerCode: '772-053',
|
||||||
|
birthDate: '1990-05-20',
|
||||||
|
birthPlace: 'Москва',
|
||||||
|
registrationAddress: 'Адрес 1',
|
||||||
|
residenceAddress: 'Адрес 2',
|
||||||
|
citizenship: 'RU',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(valid.hasValidPassportData, isTrue);
|
||||||
|
expect(valid.copyWith(inn: '123').hasValidPassportData, isFalse);
|
||||||
|
expect(valid.copyWith(lastName: '').hasValidPassportData, isFalse);
|
||||||
|
expect(
|
||||||
|
valid.copyWith(birthDate: '2020-02-31').hasValidPassportData,
|
||||||
|
isFalse,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import 'package:eatvio_app/features/consultant/become_consultant/enums/alfa_scoring_status.dart';
|
||||||
import 'package:eatvio_app/features/consultant/become_consultant/models/consultation_request_model.dart';
|
import 'package:eatvio_app/features/consultant/become_consultant/models/consultation_request_model.dart';
|
||||||
import 'package:eatvio_app/features/profile/models/profile_model.dart';
|
import 'package:eatvio_app/features/profile/models/profile_model.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
@@ -46,7 +47,11 @@ void main() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('fromJson handles null additional field', () {
|
test('fromJson handles null additional field', () {
|
||||||
final json = {'title': 'Test title', 'status': 'canceled', 'additional': null};
|
final json = {
|
||||||
|
'title': 'Test title',
|
||||||
|
'status': 'canceled',
|
||||||
|
'additional': null,
|
||||||
|
};
|
||||||
|
|
||||||
final model = CanceledModerationModel.fromJson(json);
|
final model = CanceledModerationModel.fromJson(json);
|
||||||
|
|
||||||
@@ -56,7 +61,11 @@ void main() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('toJson serializes all fields correctly', () {
|
test('toJson serializes all fields correctly', () {
|
||||||
const model = CanceledModerationModel(title: 'Test title', status: ModerationStatus.canceled, additional: ['reason1', 'reason2']);
|
const model = CanceledModerationModel(
|
||||||
|
title: 'Test title',
|
||||||
|
status: ModerationStatus.canceled,
|
||||||
|
additional: ['reason1', 'reason2'],
|
||||||
|
);
|
||||||
|
|
||||||
final json = model.toJson();
|
final json = model.toJson();
|
||||||
|
|
||||||
@@ -122,10 +131,67 @@ void main() {
|
|||||||
expect(model.canceledModeration!.status, ModerationStatus.canceled);
|
expect(model.canceledModeration!.status, ModerationStatus.canceled);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('fromJson parses legal_entity_resource', () {
|
||||||
|
final json = {
|
||||||
|
'rating_place': 5,
|
||||||
|
'education_resource': {'request_state': 'approved'},
|
||||||
|
'cert_photos': [],
|
||||||
|
'legal_entity_resource': {
|
||||||
|
'id': 1,
|
||||||
|
'type': 'individualPerson',
|
||||||
|
'inn': '123456789012',
|
||||||
|
'snils': '123-456-789 00',
|
||||||
|
'state': 'created',
|
||||||
|
'alfa_scoring_status': 'scoring_failed',
|
||||||
|
'alfa_scoring_request_id': 'abc',
|
||||||
|
'passport_series': '1234',
|
||||||
|
'passport_number': '567890',
|
||||||
|
'passport_issue_date': '2020-01-15T00:00:00.000000Z',
|
||||||
|
'passport_issuer': 'ОВД',
|
||||||
|
'passport_issuer_code': '772-053',
|
||||||
|
'birth_date': '1990-05-20',
|
||||||
|
'birth_place': 'Москва',
|
||||||
|
'registration_address': 'Адрес 1',
|
||||||
|
'residence_address': 'Адрес 2',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
final model = ConsultationRequestModel.fromJson(json);
|
||||||
|
|
||||||
|
expect(model.legalEntity, isNotNull);
|
||||||
|
expect(model.legalEntity!.type, 'individualPerson');
|
||||||
|
expect(model.legalEntity!.inn, '123456789012');
|
||||||
|
expect(model.legalEntity!.passportSeries, '1234');
|
||||||
|
expect(model.legalEntity!.normalizedPassportIssueDate, '2020-01-15');
|
||||||
|
expect(
|
||||||
|
model.legalEntity!.alfaScoringStatus,
|
||||||
|
AlfaScoringStatus.scoringFailed,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('normalizeApiDate strips time from DateTime.toString format', () {
|
||||||
|
expect(
|
||||||
|
LegalEntityModel.normalizeApiDate('2023-09-22 00:00:00.000'),
|
||||||
|
'2023-09-22',
|
||||||
|
);
|
||||||
|
expect(LegalEntityModel.normalizeApiDate('2023-09-22'), '2023-09-22');
|
||||||
|
expect(LegalEntityModel.normalizeApiDate(null), isNull);
|
||||||
|
});
|
||||||
|
|
||||||
test('toJson serializes ConsultationRequestModel correctly', () {
|
test('toJson serializes ConsultationRequestModel correctly', () {
|
||||||
const educationModel = EducationModel(requestState: ModerationStatus.approved);
|
const educationModel = EducationModel(
|
||||||
const photos = [CertPhotosModel(url: 'https://example.com/photo1.jpg'), CertPhotosModel(url: 'https://example.com/photo2.jpg')];
|
requestState: ModerationStatus.approved,
|
||||||
const model = ConsultationRequestModel(ratingPlace: 5, educationModel: educationModel, photos: photos, canceledModeration: null);
|
);
|
||||||
|
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();
|
final json = model.toJson();
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ void main() {
|
|||||||
acceptOffer: false,
|
acceptOffer: false,
|
||||||
loading: false,
|
loading: false,
|
||||||
requestModel: null,
|
requestModel: null,
|
||||||
isUpdating: false
|
isUpdating: false,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(state.consultant, consultant);
|
expect(state.consultant, consultant);
|
||||||
@@ -20,6 +20,8 @@ void main() {
|
|||||||
expect(state.loading, false);
|
expect(state.loading, false);
|
||||||
expect(state.requestModel, isNull);
|
expect(state.requestModel, isNull);
|
||||||
expect(state.certPhotos, isEmpty);
|
expect(state.certPhotos, isEmpty);
|
||||||
|
expect(state.awaitingScoringOutcome, isFalse);
|
||||||
|
expect(state.fieldErrors, isEmpty);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('creates instance with custom values', () {
|
test('creates instance with custom values', () {
|
||||||
@@ -38,7 +40,7 @@ void main() {
|
|||||||
loading: true,
|
loading: true,
|
||||||
requestModel: null,
|
requestModel: null,
|
||||||
certPhotos: [certPhoto],
|
certPhotos: [certPhoto],
|
||||||
isUpdating: false
|
isUpdating: false,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(state.acceptOffer, true);
|
expect(state.acceptOffer, true);
|
||||||
@@ -54,7 +56,7 @@ void main() {
|
|||||||
acceptOffer: false,
|
acceptOffer: false,
|
||||||
loading: false,
|
loading: false,
|
||||||
requestModel: null,
|
requestModel: null,
|
||||||
isUpdating: false
|
isUpdating: false,
|
||||||
);
|
);
|
||||||
|
|
||||||
final updatedState = originalState.copyWith(
|
final updatedState = originalState.copyWith(
|
||||||
@@ -92,7 +94,7 @@ void main() {
|
|||||||
loading: false,
|
loading: false,
|
||||||
requestModel: null,
|
requestModel: null,
|
||||||
certPhotos: [certPhoto1],
|
certPhotos: [certPhoto1],
|
||||||
isUpdating: false
|
isUpdating: false,
|
||||||
);
|
);
|
||||||
|
|
||||||
final updatedState = originalState.copyWith(
|
final updatedState = originalState.copyWith(
|
||||||
@@ -100,9 +102,14 @@ void main() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
expect(updatedState.certPhotos.length, 2);
|
expect(updatedState.certPhotos.length, 2);
|
||||||
expect(updatedState.certPhotos.first.url, 'https://example.com/photo1.jpg');
|
expect(
|
||||||
expect(updatedState.certPhotos.last.url, 'https://example.com/photo2.jpg');
|
updatedState.certPhotos.first.url,
|
||||||
|
'https://example.com/photo1.jpg',
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
updatedState.certPhotos.last.url,
|
||||||
|
'https://example.com/photo2.jpg',
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
|
import 'package:dio/dio.dart';
|
||||||
|
import 'package:eatvio_app/features/consultant/become_consultant/enums/alfa_scoring_status.dart';
|
||||||
import 'package:eatvio_app/features/consultant/become_consultant/models/consultant_cert_photo_model.dart';
|
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/models/consultant_profile_model.dart';
|
||||||
import 'package:eatvio_app/features/consultant/become_consultant/models/consultation_request_model.dart';
|
import 'package:eatvio_app/features/consultant/become_consultant/models/consultation_request_model.dart';
|
||||||
|
import 'package:eatvio_app/features/consultant/become_consultant/models/sbp_bank_model.dart';
|
||||||
import 'package:eatvio_app/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_cubit.dart';
|
import 'package:eatvio_app/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_cubit.dart';
|
||||||
import 'package:eatvio_app/features/consultant/become_consultant/repositories/consultant_repository.dart';
|
import 'package:eatvio_app/features/consultant/become_consultant/repositories/consultant_repository.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
@@ -23,16 +26,50 @@ void main() {
|
|||||||
late RegisterConsultantCubit cubit;
|
late RegisterConsultantCubit cubit;
|
||||||
late ConsultationRequestModel requestModel;
|
late ConsultationRequestModel requestModel;
|
||||||
late List<ConsultantCertPhotoModel> certPhotosFromFixture;
|
late List<ConsultantCertPhotoModel> certPhotosFromFixture;
|
||||||
|
late List<SbpBankModel> sbpBanksFromFixture;
|
||||||
late ConsultantProfileModel consultantProfile;
|
late ConsultantProfileModel consultantProfile;
|
||||||
|
|
||||||
|
ConsultationRequestModel requestWithScoring(String scoringStatus) {
|
||||||
|
final json = FixtureReader.readAsMap(
|
||||||
|
'consultant/consultation_request.json',
|
||||||
|
);
|
||||||
|
final legal = Map<String, dynamic>.from(
|
||||||
|
json['legal_entity_resource'] as Map,
|
||||||
|
);
|
||||||
|
legal['alfa_scoring_status'] = scoringStatus;
|
||||||
|
json['legal_entity_resource'] = legal;
|
||||||
|
return ConsultationRequestModel.fromJson(json);
|
||||||
|
}
|
||||||
|
|
||||||
setUp(() {
|
setUp(() {
|
||||||
repository = MockConsultantRepository();
|
repository = MockConsultantRepository();
|
||||||
cubit = RegisterConsultantCubit(consultantRepository: repository);
|
cubit = RegisterConsultantCubit(
|
||||||
requestModel = ConsultationRequestModel.fromJson(FixtureReader.readAsMap('consultant/consultation_request.json'));
|
consultantRepository: repository,
|
||||||
certPhotosFromFixture = FixtureReader.readAsList(
|
scoringPollInterval: const Duration(milliseconds: 30),
|
||||||
'consultant/consultant_cert_photos.json',
|
);
|
||||||
).map((item) => ConsultantCertPhotoModel.fromJson(item as Map<String, dynamic>)).toList();
|
requestModel = ConsultationRequestModel.fromJson(
|
||||||
consultantProfile = ConsultantProfileModel.fromJson(FixtureReader.readAsMap('consultant/consultant_profile_request.json'));
|
FixtureReader.readAsMap('consultant/consultation_request.json'),
|
||||||
|
);
|
||||||
|
certPhotosFromFixture =
|
||||||
|
FixtureReader.readAsList('consultant/consultant_cert_photos.json')
|
||||||
|
.map(
|
||||||
|
(item) => ConsultantCertPhotoModel.fromJson(
|
||||||
|
item as Map<String, dynamic>,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toList();
|
||||||
|
sbpBanksFromFixture =
|
||||||
|
FixtureReader.readAsList('consultant/sbp_banks.json')
|
||||||
|
.map(
|
||||||
|
(item) => SbpBankModel.fromJson(item as Map<String, dynamic>),
|
||||||
|
)
|
||||||
|
.toList();
|
||||||
|
consultantProfile = ConsultantProfileModel.fromJson(
|
||||||
|
FixtureReader.readAsMap('consultant/consultant_profile_request.json'),
|
||||||
|
);
|
||||||
|
when(
|
||||||
|
() => repository.getSbpBanks(),
|
||||||
|
).thenAnswer((_) async => sbpBanksFromFixture);
|
||||||
});
|
});
|
||||||
|
|
||||||
tearDown(() async {
|
tearDown(() async {
|
||||||
@@ -44,11 +81,16 @@ void main() {
|
|||||||
expect(cubit.state.consultant.hasMedicalEducation, isTrue);
|
expect(cubit.state.consultant.hasMedicalEducation, isTrue);
|
||||||
expect(cubit.state.acceptOffer, isFalse);
|
expect(cubit.state.acceptOffer, isFalse);
|
||||||
expect(cubit.state.requestModel, isNull);
|
expect(cubit.state.requestModel, isNull);
|
||||||
|
expect(cubit.state.alfaScoringStatus, AlfaScoringStatus.none);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('getMyRequestStatus hydrates state with backend data', () async {
|
test('getMyRequestStatus hydrates state with backend data', () async {
|
||||||
when(() => repository.getMyRequestStatus(any<String>())).thenAnswer((_) async => requestModel);
|
when(
|
||||||
when(() => repository.getConsultantCertPhotos()).thenAnswer((_) async => certPhotosFromFixture);
|
() => repository.getMyRequestStatus(any<String>()),
|
||||||
|
).thenAnswer((_) async => requestModel);
|
||||||
|
when(
|
||||||
|
() => repository.getConsultantCertPhotos(),
|
||||||
|
).thenAnswer((_) async => certPhotosFromFixture);
|
||||||
|
|
||||||
await cubit.getMyRequestStatus('my-consultant-slug');
|
await cubit.getMyRequestStatus('my-consultant-slug');
|
||||||
|
|
||||||
@@ -56,53 +98,308 @@ void main() {
|
|||||||
expect(state.loading, isFalse);
|
expect(state.loading, isFalse);
|
||||||
expect(state.requestModel, equals(requestModel));
|
expect(state.requestModel, equals(requestModel));
|
||||||
expect(state.consultant.certificates, isNotNull);
|
expect(state.consultant.certificates, isNotNull);
|
||||||
// Certificates list is built from backend courses; we just ensure it is not empty.
|
|
||||||
expect(state.consultant.certificates!.length, greaterThan(0));
|
expect(state.consultant.certificates!.length, greaterThan(0));
|
||||||
expect(state.consultant.certificates!.first.title, 'Нутрициология PRO');
|
expect(state.consultant.certificates!.first.title, 'Нутрициология PRO');
|
||||||
expect(state.consultant.socialLinks?.instaLink, isNull, reason: 'empty links are sanitized');
|
expect(
|
||||||
|
state.consultant.socialLinks?.instaLink,
|
||||||
|
isNull,
|
||||||
|
reason: 'empty links are sanitized',
|
||||||
|
);
|
||||||
expect(state.consultant.socialLinks?.vkLink, 'https://vk.com/consultant');
|
expect(state.consultant.socialLinks?.vkLink, 'https://vk.com/consultant');
|
||||||
// All cert photos from backend are kept in state (visible + hidden).
|
expect(state.consultant.legalType, 'individualPerson');
|
||||||
|
expect(state.consultant.inn, '123456789012');
|
||||||
|
expect(state.consultant.passportSeries, '1234');
|
||||||
|
expect(state.consultant.passportIssueDate, '2020-01-15');
|
||||||
|
expect(state.consultant.birthPlace, 'г. Москва');
|
||||||
expect(state.certPhotos, hasLength(3));
|
expect(state.certPhotos, hasLength(3));
|
||||||
expect(state.certPhotos.map((e) => e.id), containsAll(<int>[101, 102, 103]));
|
expect(
|
||||||
verify(() => repository.getMyRequestStatus('my-consultant-slug')).called(1);
|
state.certPhotos.map((e) => e.id),
|
||||||
|
containsAll(<int>[101, 102, 103]),
|
||||||
|
);
|
||||||
|
expect(state.sbpBanks, hasLength(2));
|
||||||
|
expect(state.sbpBanks.first.bankName, 'Сбербанк');
|
||||||
|
expect(state.alfaScoringStatus, AlfaScoringStatus.scoringFailed);
|
||||||
|
expect(state.alfaScoringStatus.canRetry, isTrue);
|
||||||
|
expect(state.isScoringPolling, isFalse);
|
||||||
|
expect(
|
||||||
|
state.awaitingScoringOutcome,
|
||||||
|
isFalse,
|
||||||
|
reason: 'hydrate must not arm success sheet',
|
||||||
|
);
|
||||||
|
verify(
|
||||||
|
() => repository.getMyRequestStatus('my-consultant-slug'),
|
||||||
|
).called(1);
|
||||||
verify(() => repository.getConsultantCertPhotos()).called(1);
|
verify(() => repository.getConsultantCertPhotos()).called(1);
|
||||||
|
verify(() => repository.getSbpBanks()).called(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
'getMyRequestStatus with processing status starts scoring poll',
|
||||||
|
() async {
|
||||||
|
final pending = requestWithScoring('scoring_pending');
|
||||||
|
final complete = requestWithScoring('scoring_complete');
|
||||||
|
var calls = 0;
|
||||||
|
when(() => repository.getMyRequestStatus(any<String>())).thenAnswer((
|
||||||
|
_,
|
||||||
|
) async {
|
||||||
|
calls++;
|
||||||
|
return calls == 1 ? pending : complete;
|
||||||
|
});
|
||||||
|
when(
|
||||||
|
() => repository.getConsultantCertPhotos(),
|
||||||
|
).thenAnswer((_) async => certPhotosFromFixture);
|
||||||
|
|
||||||
|
await cubit.getMyRequestStatus('my-consultant-slug');
|
||||||
|
|
||||||
|
expect(cubit.state.alfaScoringStatus, AlfaScoringStatus.scoringPending);
|
||||||
|
expect(cubit.state.isScoringPolling, isTrue);
|
||||||
|
|
||||||
|
await Future<void>.delayed(const Duration(milliseconds: 50));
|
||||||
|
expect(
|
||||||
|
cubit.state.alfaScoringStatus,
|
||||||
|
AlfaScoringStatus.scoringComplete,
|
||||||
|
);
|
||||||
|
expect(cubit.state.isScoringPolling, isFalse);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
test('disableLoading stops spinner and loads cert photos', () async {
|
test('disableLoading stops spinner and loads cert photos', () async {
|
||||||
when(() => repository.getConsultantCertPhotos()).thenAnswer((_) async => certPhotosFromFixture);
|
when(
|
||||||
|
() => repository.getConsultantCertPhotos(),
|
||||||
|
).thenAnswer((_) async => certPhotosFromFixture);
|
||||||
|
|
||||||
|
await cubit.disableLoading(slug: 'my-consultant-slug');
|
||||||
|
|
||||||
|
expect(cubit.state.loading, isFalse);
|
||||||
|
expect(cubit.state.certPhotos, hasLength(3));
|
||||||
|
expect(cubit.state.sbpBanks, hasLength(2));
|
||||||
|
expect(cubit.userSlug, 'my-consultant-slug');
|
||||||
|
verify(() => repository.getConsultantCertPhotos()).called(1);
|
||||||
|
verify(() => repository.getSbpBanks()).called(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('searchSbpBanks filters banks by name', () async {
|
||||||
|
when(
|
||||||
|
() => repository.getConsultantCertPhotos(),
|
||||||
|
).thenAnswer((_) async => certPhotosFromFixture);
|
||||||
await cubit.disableLoading();
|
await cubit.disableLoading();
|
||||||
|
|
||||||
expect(cubit.state.loading, isFalse);
|
final results = cubit.searchSbpBanks('сбер');
|
||||||
// disableLoading now keeps all photos from repository.
|
|
||||||
expect(cubit.state.certPhotos, hasLength(3));
|
expect(results, hasLength(1));
|
||||||
verify(() => repository.getConsultantCertPhotos()).called(1);
|
expect(results.first.bankMemberId, '100000000111');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('updateConsultantProfileModel replaces consultant data and offer flag', () {
|
test('findSbpBankById returns matching bank', () async {
|
||||||
cubit.updateConsultantProfileModel(consultantProfile, true);
|
|
||||||
|
|
||||||
expect(cubit.state.consultant, consultantProfile);
|
|
||||||
expect(cubit.state.acceptOffer, isTrue);
|
|
||||||
expect(cubit.state.loading, isFalse);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('registerConsultant forwards payload with update=false by default', () async {
|
|
||||||
when(
|
when(
|
||||||
() => repository.registerConsultant(
|
() => repository.getConsultantCertPhotos(),
|
||||||
consultant: any<ConsultantProfileModel>(named: 'consultant'),
|
).thenAnswer((_) async => certPhotosFromFixture);
|
||||||
update: any<bool>(named: 'update'),
|
await cubit.disableLoading();
|
||||||
),
|
|
||||||
).thenAnswer((_) async {});
|
|
||||||
|
|
||||||
await cubit.registerConsultant();
|
final bank = cubit.findSbpBankById('100000000004');
|
||||||
|
|
||||||
verify(() => repository.registerConsultant(consultant: cubit.state.consultant, update: false)).called(1);
|
expect(bank?.bankName, 'Т-Банк');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('registerConsultant sends update=true after getMyRequestStatus call', () async {
|
test(
|
||||||
when(() => repository.getMyRequestStatus(any<String>())).thenAnswer((_) async => requestModel);
|
'updateConsultantProfileModel replaces consultant data and offer flag',
|
||||||
when(() => repository.getConsultantCertPhotos()).thenAnswer((_) async => certPhotosFromFixture);
|
() {
|
||||||
|
cubit.updateConsultantProfileModel(consultantProfile, true);
|
||||||
|
|
||||||
|
expect(cubit.state.consultant, consultantProfile);
|
||||||
|
expect(cubit.state.acceptOffer, isTrue);
|
||||||
|
expect(cubit.state.loading, isFalse);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
'registerConsultant forwards payload with update=false by default',
|
||||||
|
() async {
|
||||||
|
when(
|
||||||
|
() => repository.registerConsultant(
|
||||||
|
consultant: any<ConsultantProfileModel>(named: 'consultant'),
|
||||||
|
update: any<bool>(named: 'update'),
|
||||||
|
),
|
||||||
|
).thenAnswer((_) async {});
|
||||||
|
|
||||||
|
cubit.updateConsultantProfileModel(consultantProfile);
|
||||||
|
cubit.updateAgreements(acceptOffer: true, acceptFrdoAgreement: true);
|
||||||
|
await cubit.registerConsultant();
|
||||||
|
|
||||||
|
final captured =
|
||||||
|
verify(
|
||||||
|
() => repository.registerConsultant(
|
||||||
|
consultant: captureAny<ConsultantProfileModel>(
|
||||||
|
named: 'consultant',
|
||||||
|
),
|
||||||
|
update: false,
|
||||||
|
),
|
||||||
|
).captured.single
|
||||||
|
as ConsultantProfileModel;
|
||||||
|
|
||||||
|
expect(captured.legalType, 'individualPerson');
|
||||||
|
expect(captured.agreementAccepted, isTrue);
|
||||||
|
expect(captured.frdoAgreementAccepted, isTrue);
|
||||||
|
expect(captured.inn, isNotNull);
|
||||||
|
expect(
|
||||||
|
cubit.state.alfaScoringStatus,
|
||||||
|
AlfaScoringStatus.beneficiaryCreating,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
cubit.state.isScoringPolling,
|
||||||
|
isFalse,
|
||||||
|
reason: 'no slug for poll',
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
cubit.state.awaitingScoringOutcome,
|
||||||
|
isFalse,
|
||||||
|
reason: 'cannot await scoring without slug',
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
'getMyRequestStatus with scoring_complete does not arm awaiting flag',
|
||||||
|
() async {
|
||||||
|
when(
|
||||||
|
() => repository.getMyRequestStatus(any<String>()),
|
||||||
|
).thenAnswer((_) async => requestWithScoring('scoring_complete'));
|
||||||
|
when(
|
||||||
|
() => repository.getConsultantCertPhotos(),
|
||||||
|
).thenAnswer((_) async => certPhotosFromFixture);
|
||||||
|
|
||||||
|
await cubit.getMyRequestStatus('my-consultant-slug');
|
||||||
|
|
||||||
|
expect(
|
||||||
|
cubit.state.alfaScoringStatus,
|
||||||
|
AlfaScoringStatus.scoringComplete,
|
||||||
|
);
|
||||||
|
expect(cubit.state.awaitingScoringOutcome, isFalse);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
'registerConsultant with slug starts scoring poll after submit',
|
||||||
|
() async {
|
||||||
|
when(
|
||||||
|
() => repository.getConsultantCertPhotos(),
|
||||||
|
).thenAnswer((_) async => certPhotosFromFixture);
|
||||||
|
when(
|
||||||
|
() => repository.getMyRequestStatus(any<String>()),
|
||||||
|
).thenAnswer((_) async => requestWithScoring('scoring_complete'));
|
||||||
|
when(
|
||||||
|
() => repository.registerConsultant(
|
||||||
|
consultant: any<ConsultantProfileModel>(named: 'consultant'),
|
||||||
|
update: any<bool>(named: 'update'),
|
||||||
|
),
|
||||||
|
).thenAnswer((_) async {});
|
||||||
|
|
||||||
|
await cubit.disableLoading(slug: 'my-consultant-slug');
|
||||||
|
cubit.updateConsultantProfileModel(consultantProfile);
|
||||||
|
cubit.updateAgreements(acceptOffer: true, acceptFrdoAgreement: true);
|
||||||
|
await cubit.registerConsultant();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
cubit.state.alfaScoringStatus,
|
||||||
|
AlfaScoringStatus.beneficiaryCreating,
|
||||||
|
);
|
||||||
|
expect(cubit.state.isScoringPolling, isTrue);
|
||||||
|
expect(cubit.state.awaitingScoringOutcome, isTrue);
|
||||||
|
|
||||||
|
await Future<void>.delayed(const Duration(milliseconds: 50));
|
||||||
|
expect(
|
||||||
|
cubit.state.alfaScoringStatus,
|
||||||
|
AlfaScoringStatus.scoringComplete,
|
||||||
|
);
|
||||||
|
expect(cubit.state.isScoringPolling, isFalse);
|
||||||
|
expect(
|
||||||
|
cubit.state.awaitingScoringOutcome,
|
||||||
|
isFalse,
|
||||||
|
reason: 'flag clears on terminal scoring for one-shot success sheet',
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
'scoring poll failed clears awaiting flag without leaving processing',
|
||||||
|
() async {
|
||||||
|
when(
|
||||||
|
() => repository.getConsultantCertPhotos(),
|
||||||
|
).thenAnswer((_) async => certPhotosFromFixture);
|
||||||
|
when(
|
||||||
|
() => repository.getMyRequestStatus(any<String>()),
|
||||||
|
).thenAnswer((_) async => requestWithScoring('scoring_failed'));
|
||||||
|
when(
|
||||||
|
() => repository.registerConsultant(
|
||||||
|
consultant: any<ConsultantProfileModel>(named: 'consultant'),
|
||||||
|
update: any<bool>(named: 'update'),
|
||||||
|
),
|
||||||
|
).thenAnswer((_) async {});
|
||||||
|
|
||||||
|
await cubit.disableLoading(slug: 'my-consultant-slug');
|
||||||
|
cubit.updateConsultantProfileModel(consultantProfile);
|
||||||
|
cubit.updateAgreements(acceptOffer: true, acceptFrdoAgreement: true);
|
||||||
|
await cubit.registerConsultant();
|
||||||
|
|
||||||
|
await Future<void>.delayed(const Duration(milliseconds: 50));
|
||||||
|
expect(cubit.state.alfaScoringStatus, AlfaScoringStatus.scoringFailed);
|
||||||
|
expect(cubit.state.isScoringPolling, isFalse);
|
||||||
|
expect(cubit.state.awaitingScoringOutcome, isFalse);
|
||||||
|
expect(cubit.state.alfaScoringStatus.canRetry, isTrue);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
'scoring poll keeps processing UI while API still returns none',
|
||||||
|
() async {
|
||||||
|
when(
|
||||||
|
() => repository.getConsultantCertPhotos(),
|
||||||
|
).thenAnswer((_) async => certPhotosFromFixture);
|
||||||
|
var calls = 0;
|
||||||
|
when(() => repository.getMyRequestStatus(any<String>())).thenAnswer((
|
||||||
|
_,
|
||||||
|
) async {
|
||||||
|
calls++;
|
||||||
|
if (calls <= 2) {
|
||||||
|
return requestWithScoring('none');
|
||||||
|
}
|
||||||
|
return requestWithScoring('scoring_complete');
|
||||||
|
});
|
||||||
|
when(
|
||||||
|
() => repository.registerConsultant(
|
||||||
|
consultant: any<ConsultantProfileModel>(named: 'consultant'),
|
||||||
|
update: any<bool>(named: 'update'),
|
||||||
|
),
|
||||||
|
).thenAnswer((_) async {});
|
||||||
|
|
||||||
|
await cubit.disableLoading(slug: 'my-consultant-slug');
|
||||||
|
cubit.updateConsultantProfileModel(consultantProfile);
|
||||||
|
cubit.updateAgreements(acceptOffer: true, acceptFrdoAgreement: true);
|
||||||
|
await cubit.registerConsultant();
|
||||||
|
|
||||||
|
await Future<void>.delayed(const Duration(milliseconds: 50));
|
||||||
|
expect(
|
||||||
|
cubit.state.alfaScoringStatus,
|
||||||
|
AlfaScoringStatus.beneficiaryCreating,
|
||||||
|
);
|
||||||
|
expect(cubit.state.isScoringPolling, isTrue);
|
||||||
|
|
||||||
|
await Future<void>.delayed(const Duration(milliseconds: 80));
|
||||||
|
expect(
|
||||||
|
cubit.state.alfaScoringStatus,
|
||||||
|
AlfaScoringStatus.scoringComplete,
|
||||||
|
);
|
||||||
|
expect(cubit.state.isScoringPolling, isFalse);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test('registerConsultant sends legal payload with update=true', () async {
|
||||||
|
when(
|
||||||
|
() => repository.getMyRequestStatus(any<String>()),
|
||||||
|
).thenAnswer((_) async => requestModel);
|
||||||
|
when(
|
||||||
|
() => repository.getConsultantCertPhotos(),
|
||||||
|
).thenAnswer((_) async => certPhotosFromFixture);
|
||||||
when(
|
when(
|
||||||
() => repository.registerConsultant(
|
() => repository.registerConsultant(
|
||||||
consultant: any<ConsultantProfileModel>(named: 'consultant'),
|
consultant: any<ConsultantProfileModel>(named: 'consultant'),
|
||||||
@@ -113,21 +410,170 @@ void main() {
|
|||||||
await cubit.getMyRequestStatus('my-consultant-slug');
|
await cubit.getMyRequestStatus('my-consultant-slug');
|
||||||
await cubit.registerConsultant();
|
await cubit.registerConsultant();
|
||||||
|
|
||||||
verify(() => repository.registerConsultant(consultant: cubit.state.consultant, update: true)).called(1);
|
final captured =
|
||||||
|
verify(
|
||||||
|
() => repository.registerConsultant(
|
||||||
|
consultant: captureAny<ConsultantProfileModel>(
|
||||||
|
named: 'consultant',
|
||||||
|
),
|
||||||
|
update: true,
|
||||||
|
),
|
||||||
|
).captured.single
|
||||||
|
as ConsultantProfileModel;
|
||||||
|
|
||||||
|
final json = captured.toJson();
|
||||||
|
expect(json['legal_type'], 'individualPerson');
|
||||||
|
expect(json['inn'], '123456789012');
|
||||||
|
expect(json['passport_series'], '1234');
|
||||||
|
expect(json.containsKey('agreement_accepted'), isFalse);
|
||||||
|
expect(json.containsKey('frdo_agreement_accepted'), isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('retryScoring calls update and restarts poll', () async {
|
||||||
|
when(
|
||||||
|
() => repository.getMyRequestStatus(any<String>()),
|
||||||
|
).thenAnswer((_) async => requestModel);
|
||||||
|
when(
|
||||||
|
() => repository.getConsultantCertPhotos(),
|
||||||
|
).thenAnswer((_) async => certPhotosFromFixture);
|
||||||
|
when(
|
||||||
|
() => repository.registerConsultant(
|
||||||
|
consultant: any<ConsultantProfileModel>(named: 'consultant'),
|
||||||
|
update: any<bool>(named: 'update'),
|
||||||
|
),
|
||||||
|
).thenAnswer((_) async {});
|
||||||
|
|
||||||
|
await cubit.getMyRequestStatus('my-consultant-slug');
|
||||||
|
expect(cubit.state.alfaScoringStatus, AlfaScoringStatus.scoringFailed);
|
||||||
|
|
||||||
|
when(
|
||||||
|
() => repository.getMyRequestStatus(any<String>()),
|
||||||
|
).thenAnswer((_) async => requestWithScoring('scoring_complete'));
|
||||||
|
await cubit.retryScoring();
|
||||||
|
|
||||||
|
verify(
|
||||||
|
() => repository.registerConsultant(
|
||||||
|
consultant: any<ConsultantProfileModel>(named: 'consultant'),
|
||||||
|
update: true,
|
||||||
|
),
|
||||||
|
).called(1);
|
||||||
|
|
||||||
|
await Future<void>.delayed(const Duration(milliseconds: 50));
|
||||||
|
expect(cubit.state.alfaScoringStatus, AlfaScoringStatus.scoringComplete);
|
||||||
|
expect(cubit.state.isScoringPolling, isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
'retryScoring writes fieldErrors when passport data is invalid',
|
||||||
|
() async {
|
||||||
|
cubit.emit(
|
||||||
|
cubit.state.copyWith(
|
||||||
|
alfaScoringStatus: AlfaScoringStatus.scoringFailed,
|
||||||
|
loading: false,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await cubit.retryScoring();
|
||||||
|
|
||||||
|
expect(cubit.state.fieldErrors['inn'], 'Укажите ИНН');
|
||||||
|
expect(cubit.state.alfaScoringStatus, AlfaScoringStatus.scoringFailed);
|
||||||
|
verifyNever(
|
||||||
|
() => repository.registerConsultant(
|
||||||
|
consultant: any<ConsultantProfileModel>(named: 'consultant'),
|
||||||
|
update: any<bool>(named: 'update'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test('registerConsultant writes fieldErrors without calling API', () async {
|
||||||
|
when(
|
||||||
|
() => repository.registerConsultant(
|
||||||
|
consultant: any<ConsultantProfileModel>(named: 'consultant'),
|
||||||
|
update: any<bool>(named: 'update'),
|
||||||
|
),
|
||||||
|
).thenAnswer((_) async {});
|
||||||
|
|
||||||
|
await cubit.registerConsultant();
|
||||||
|
|
||||||
|
expect(cubit.state.fieldErrors['last_name'], 'Укажите фамилию');
|
||||||
|
expect(cubit.state.fieldErrors['inn'], 'Укажите ИНН');
|
||||||
|
expect(cubit.state.fieldErrors['birth_date'], 'Укажите дату рождения');
|
||||||
|
expect(cubit.state.error, isNull);
|
||||||
|
verifyNever(
|
||||||
|
() => repository.registerConsultant(
|
||||||
|
consultant: any<ConsultantProfileModel>(named: 'consultant'),
|
||||||
|
update: any<bool>(named: 'update'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('editing a field clears only that field error', () async {
|
||||||
|
await cubit.registerConsultant();
|
||||||
|
expect(cubit.state.fieldErrors.containsKey('last_name'), isTrue);
|
||||||
|
expect(cubit.state.fieldErrors.containsKey('inn'), isTrue);
|
||||||
|
|
||||||
|
cubit.updateConsultantProfileModel(
|
||||||
|
cubit.state.consultant.copyWith(lastName: 'Иванов'),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(cubit.state.fieldErrors.containsKey('last_name'), isFalse);
|
||||||
|
expect(cubit.state.fieldErrors['inn'], 'Укажите ИНН');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('registerConsultant maps Laravel 422 into fieldErrors', () async {
|
||||||
|
when(
|
||||||
|
() => repository.registerConsultant(
|
||||||
|
consultant: any<ConsultantProfileModel>(named: 'consultant'),
|
||||||
|
update: any<bool>(named: 'update'),
|
||||||
|
),
|
||||||
|
).thenThrow(
|
||||||
|
DioException(
|
||||||
|
requestOptions: RequestOptions(
|
||||||
|
path: '/api/v2/consultant_request/registration',
|
||||||
|
),
|
||||||
|
response: Response(
|
||||||
|
requestOptions: RequestOptions(
|
||||||
|
path: '/api/v2/consultant_request/registration',
|
||||||
|
),
|
||||||
|
statusCode: 422,
|
||||||
|
data: {
|
||||||
|
'message': 'The given data was invalid.',
|
||||||
|
'errors': {
|
||||||
|
'inn': ['The inn format is invalid.'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
type: DioExceptionType.badResponse,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
cubit.updateConsultantProfileModel(consultantProfile);
|
||||||
|
cubit.updateAgreements(acceptOffer: true, acceptFrdoAgreement: true);
|
||||||
|
await cubit.registerConsultant();
|
||||||
|
|
||||||
|
expect(cubit.state.fieldErrors['inn'], 'Некорректный ИНН');
|
||||||
|
expect(cubit.state.error, contains('Некорректный ИНН'));
|
||||||
|
expect(cubit.state.awaitingScoringOutcome, isFalse);
|
||||||
|
expect(cubit.state.isScoringPolling, isFalse);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('removeCertPhoto marks cert as deleted locally', () async {
|
test('removeCertPhoto marks cert as deleted locally', () async {
|
||||||
when(() => repository.getMyRequestStatus(any<String>())).thenAnswer((_) async => requestModel);
|
when(
|
||||||
when(() => repository.getConsultantCertPhotos()).thenAnswer((_) async => certPhotosFromFixture);
|
() => repository.getMyRequestStatus(any<String>()),
|
||||||
when(() => repository.hideConsultantCertPhoto(any<int>())).thenAnswer((_) async {});
|
).thenAnswer((_) async => requestModel);
|
||||||
|
when(
|
||||||
|
() => repository.getConsultantCertPhotos(),
|
||||||
|
).thenAnswer((_) async => certPhotosFromFixture);
|
||||||
|
when(
|
||||||
|
() => repository.hideConsultantCertPhoto(any<int>()),
|
||||||
|
).thenAnswer((_) async {});
|
||||||
|
|
||||||
await cubit.getMyRequestStatus('my-consultant-slug');
|
await cubit.getMyRequestStatus('my-consultant-slug');
|
||||||
// All photos from backend are present in state.
|
|
||||||
expect(cubit.state.certPhotos, hasLength(3));
|
expect(cubit.state.certPhotos, hasLength(3));
|
||||||
|
|
||||||
await cubit.removeCertPhoto(101, null);
|
await cubit.removeCertPhoto(101, null);
|
||||||
|
|
||||||
// Current implementation only tracks deleted IDs; photos list remains unchanged here.
|
|
||||||
verifyNever(() => repository.hideConsultantCertPhoto(any<int>()));
|
verifyNever(() => repository.hideConsultantCertPhoto(any<int>()));
|
||||||
expect(cubit.state.deletedPhotoIds, contains(101));
|
expect(cubit.state.deletedPhotoIds, contains(101));
|
||||||
expect(cubit.state.certPhotos, hasLength(3));
|
expect(cubit.state.certPhotos, hasLength(3));
|
||||||
@@ -136,7 +582,9 @@ void main() {
|
|||||||
test('uploadCertPhotos - не загружает если список пуст', () async {
|
test('uploadCertPhotos - не загружает если список пуст', () async {
|
||||||
await cubit.addCertPhotos([]);
|
await cubit.addCertPhotos([]);
|
||||||
|
|
||||||
verifyNever(() => repository.storeConsultantCertPhotos(files: any(named: 'files')));
|
verifyNever(
|
||||||
|
() => repository.storeConsultantCertPhotos(files: any(named: 'files')),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('updateConsultantProfileModel - обновляет без acceptOffer', () {
|
test('updateConsultantProfileModel - обновляет без acceptOffer', () {
|
||||||
@@ -147,4 +595,38 @@ void main() {
|
|||||||
expect(cubit.state.loading, isFalse);
|
expect(cubit.state.loading, isFalse);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
group('AlfaScoringStatus', () {
|
||||||
|
test('fromString maps known values', () {
|
||||||
|
expect(
|
||||||
|
AlfaScoringStatus.fromString('beneficiary_creating'),
|
||||||
|
AlfaScoringStatus.beneficiaryCreating,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
AlfaScoringStatus.fromString('scoring_pending'),
|
||||||
|
AlfaScoringStatus.scoringPending,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
AlfaScoringStatus.fromString('scoring_complete'),
|
||||||
|
AlfaScoringStatus.scoringComplete,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
AlfaScoringStatus.fromString('scoring_failed'),
|
||||||
|
AlfaScoringStatus.scoringFailed,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
AlfaScoringStatus.fromString('beneficiary_failed'),
|
||||||
|
AlfaScoringStatus.beneficiaryFailed,
|
||||||
|
);
|
||||||
|
expect(AlfaScoringStatus.fromString(null), AlfaScoringStatus.none);
|
||||||
|
expect(AlfaScoringStatus.fromString('unknown'), AlfaScoringStatus.none);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('helpers', () {
|
||||||
|
expect(AlfaScoringStatus.scoringPending.isProcessing, isTrue);
|
||||||
|
expect(AlfaScoringStatus.scoringFailed.canRetry, isTrue);
|
||||||
|
expect(AlfaScoringStatus.scoringComplete.isComplete, isTrue);
|
||||||
|
expect(AlfaScoringStatus.scoringComplete.isTerminal, isTrue);
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user