forked from eatvio/eatvio_app
Compare commits
33 Commits
post_creat
...
1-3-42_bug
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
16a13488d1 | ||
|
|
de7bf8dc19 | ||
|
|
6265e865e9 | ||
| 1550b7ea0b | |||
|
|
db10895ebb | ||
|
|
a7361d3ab1 | ||
| 1c54063a35 | |||
| efae7fc958 | |||
|
|
90ac4e750d | ||
|
|
6d7419c283 | ||
| caf45a9050 | |||
|
|
0a5e7d95c4 | ||
|
|
85482173ad | ||
| 65f7992d40 | |||
|
|
52a01bee12 | ||
| 179fc0ad99 | |||
|
|
8494e61eec | ||
|
|
5c2458ecad | ||
| b63bab8bf5 | |||
| 7790014726 | |||
| b679e90ba2 | |||
| 2474c5bea3 | |||
| 7f278d0f59 | |||
|
|
53426c858c | ||
| 88f0cde88f | |||
|
|
a5fcf62524 | ||
| a2e41a2ff5 | |||
|
|
5b1beafbab | ||
| 4eadecd6f2 | |||
|
|
6292db07a1 | ||
| ab29b6b329 | |||
|
|
af5d182a79 | ||
| 191bb74aa4 |
@@ -59,12 +59,16 @@ jobs:
|
||||
- name: Flutter build
|
||||
run: |
|
||||
flutter --version
|
||||
flutter config --no-enable-swift-package-manager
|
||||
rm -rf ios/Podfile.lock
|
||||
flutter clean
|
||||
flutter pub get
|
||||
flutter pub run build_runner build --delete-conflicting-outputs
|
||||
flutter build ios --config-only --release
|
||||
cd ios
|
||||
pod install
|
||||
pod install --repo-update
|
||||
cd ..
|
||||
flutter build ios --config-only --release
|
||||
cd ios
|
||||
fastlane beta
|
||||
env:
|
||||
LANG: en_US.UTF-8
|
||||
|
||||
@@ -78,12 +78,7 @@
|
||||
<data android:host="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>
|
||||
</activity>
|
||||
<!-- Don't delete the meta-data below.
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
<array>
|
||||
<string>applinks: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>
|
||||
</array>
|
||||
</dict>
|
||||
|
||||
@@ -1,15 +1,11 @@
|
||||
import 'dart:async';
|
||||
import 'dart:developer';
|
||||
import 'dart:io' as io;
|
||||
import 'package:eatvio_app/core/app_config.dart';
|
||||
import 'package:eatvio_app/core/env_config.dart';
|
||||
import 'package:eatvio_app/core/error_reporting/error_report_policy.dart';
|
||||
import 'package:eatvio_app/core/locator_service.dart' as di;
|
||||
import 'package:eatvio_app/core/locator_service.dart';
|
||||
import 'package:eatvio_app/features/video_calls/services/video_call_fcm_handler.dart';
|
||||
import 'package:eatvio_app/firebase_options.dart';
|
||||
import 'package:firebase_core/firebase_core.dart';
|
||||
import 'package:firebase_messaging/firebase_messaging.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_native_splash/flutter_native_splash.dart';
|
||||
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
||||
@@ -18,7 +14,6 @@ import 'package:sentry_flutter/sentry_flutter.dart';
|
||||
|
||||
/// Класс для инициализации приложения
|
||||
class AppInitializer {
|
||||
|
||||
/// Инициализация всех сервисов приложения
|
||||
static Future<void> initialize({required AppBuildType buildType}) async {
|
||||
await _initializeEnvironment();
|
||||
@@ -42,25 +37,21 @@ class AppInitializer {
|
||||
|
||||
/// Инициализация Firebase
|
||||
static Future<void> _initializeFirebase() async {
|
||||
await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform).timeout(
|
||||
await Firebase.initializeApp(
|
||||
options: DefaultFirebaseOptions.currentPlatform,
|
||||
).timeout(
|
||||
const Duration(seconds: 10),
|
||||
onTimeout: () {
|
||||
throw TimeoutException('Firebase initialization timeout');
|
||||
},
|
||||
);
|
||||
VideoCallFcmHandler.registerBackgroundHandler();
|
||||
if (sl<AppConfig>().buildType == AppBuildType.prod) {
|
||||
try {
|
||||
await FirebaseMessaging.instance.requestPermission().timeout(const Duration(seconds: 5));
|
||||
} catch (e) {
|
||||
// Разрешение не критично, продолжаем работу
|
||||
log('Failed to request Firebase messaging permission: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Инициализация системы внедрения зависимостей
|
||||
static Future<void> _initializeDependencyInjection({required AppBuildType buildType}) async {
|
||||
static Future<void> _initializeDependencyInjection({
|
||||
required AppBuildType buildType,
|
||||
}) async {
|
||||
await di.init(buildType: buildType);
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'dart:developer';
|
||||
import 'dart:io';
|
||||
import 'package:eatvio_app/core/app_config.dart';
|
||||
import 'package:eatvio_app/core/app_initializer.dart';
|
||||
import 'package:eatvio_app/core/notifications/notification_permission_requester.dart';
|
||||
import 'package:eatvio_app/core/utils/toasts.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:device_info_plus/device_info_plus.dart';
|
||||
@@ -16,9 +17,14 @@ import 'package:flutter_callkit_incoming/flutter_callkit_incoming.dart';
|
||||
|
||||
class FirebaseApi {
|
||||
final ErrorReporter _errorReporter;
|
||||
final NotificationPermissionRequester _notificationPermissionRequester;
|
||||
final _firebaseMessaging = FirebaseMessaging.instance;
|
||||
|
||||
FirebaseApi({required ErrorReporter errorReporter}) : _errorReporter = errorReporter;
|
||||
FirebaseApi({
|
||||
required ErrorReporter errorReporter,
|
||||
NotificationPermissionRequester? notificationPermissionRequester,
|
||||
}) : _errorReporter = errorReporter,
|
||||
_notificationPermissionRequester = notificationPermissionRequester ?? NotificationPermissionRequester();
|
||||
|
||||
Future<String?> getToken() async {
|
||||
String? token;
|
||||
@@ -29,16 +35,6 @@ class FirebaseApi {
|
||||
onTimeout: () => null,
|
||||
);
|
||||
} else {
|
||||
if ((sl<AppConfig>().buildType == AppBuildType.prod)) {
|
||||
try {
|
||||
await _firebaseMessaging.requestPermission().timeout(
|
||||
const Duration(seconds: 5),
|
||||
);
|
||||
} catch (e) {
|
||||
// Разрешение не критично, продолжаем
|
||||
log('Failed to request Firebase messaging permission: $e');
|
||||
}
|
||||
}
|
||||
token = await _firebaseMessaging.getToken().timeout(
|
||||
const Duration(seconds: 5),
|
||||
onTimeout: () => null,
|
||||
@@ -47,8 +43,20 @@ class FirebaseApi {
|
||||
return token;
|
||||
}
|
||||
|
||||
Future<void> _requestNotificationPermission() async {
|
||||
if (sl<AppConfig>().buildType != AppBuildType.prod) return;
|
||||
try {
|
||||
await _notificationPermissionRequester.requestOnce(
|
||||
() => _firebaseMessaging.requestPermission(),
|
||||
);
|
||||
} catch (e) {
|
||||
log('Failed to request Firebase messaging permission: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> registerAnonymousPushToken() async {
|
||||
try {
|
||||
await _requestNotificationPermission();
|
||||
final deviceInfo = DeviceInfoPlugin();
|
||||
await sl<Dio>().post(
|
||||
'/api/anonymous_token',
|
||||
@@ -73,6 +81,7 @@ class FirebaseApi {
|
||||
Future<void> registerPushToken() async {
|
||||
if (await TokenStorage.haveTokenInStorage()) {
|
||||
try {
|
||||
await _requestNotificationPermission();
|
||||
final deviceInfo = DeviceInfoPlugin();
|
||||
final isHuawei = Platform.isAndroid ? await _isHuaweiDevice() : false;
|
||||
await sl<Dio>().post(
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
class NotificationPermissionRequester {
|
||||
Future<void>? _inFlight;
|
||||
|
||||
/// Coalesces overlapping calls so the OS prompt is shown at most once at a time.
|
||||
Future<void> requestOnce(Future<void> Function() requestPermission) async {
|
||||
final inFlight = _inFlight;
|
||||
if (inFlight != null) {
|
||||
await inFlight;
|
||||
return;
|
||||
}
|
||||
|
||||
final future = requestPermission();
|
||||
_inFlight = future;
|
||||
try {
|
||||
await future;
|
||||
} finally {
|
||||
if (identical(_inFlight, future)) {
|
||||
_inFlight = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -38,13 +38,16 @@ class WsManager {
|
||||
StreamSubscription? _eventSubscription;
|
||||
StreamSubscription? _connectionEstablishedSubscription;
|
||||
|
||||
Stream<ChannelReadEvent>? _directChatStream;
|
||||
Stream<ChannelReadEvent>? _clientTypingStream;
|
||||
|
||||
Stream<ChannelReadEvent>? get newMessagesStream => _privateMessagesChannel?.bind('App\\Events\\ChatMessageUpdated');
|
||||
|
||||
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 {
|
||||
if (client == null) {
|
||||
@@ -107,6 +110,8 @@ class WsManager {
|
||||
if (data == PusherChannelsClientLifeCycleState.establishedConnection) {
|
||||
_unsubscribe();
|
||||
_subscribe();
|
||||
_privateDirectChatChannel?.subscribe();
|
||||
_presenceDirectChatChannel?.subscribe();
|
||||
}
|
||||
});
|
||||
_eventSubscription = client?.eventStream.listen((event) {
|
||||
@@ -170,6 +175,7 @@ class WsManager {
|
||||
}
|
||||
|
||||
Future<void> initPrivateDirectChatChannel(String chatId) async {
|
||||
_privateDirectChatChannel?.unsubscribe();
|
||||
final token = await TokenStorage.getTokenFromStorage();
|
||||
_privateDirectChatChannel = client?.privateChannel(
|
||||
'$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 {
|
||||
@@ -299,6 +307,8 @@ class WsManager {
|
||||
|
||||
void unsubscribeDirectChatChannel() {
|
||||
_privateDirectChatChannel?.unsubscribe();
|
||||
_directChatStream = null;
|
||||
_clientTypingStream = null;
|
||||
}
|
||||
|
||||
void unsubscribePresenceDirectChatChannel() {
|
||||
@@ -331,6 +341,8 @@ class WsManager {
|
||||
_privateDirectChatChannel = null;
|
||||
_privateMessagesChannel = null;
|
||||
_privateNotificationChannel = null;
|
||||
_directChatStream = null;
|
||||
_clientTypingStream = null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -54,16 +54,16 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
|
||||
if (currentPage < lastPage && !isLoading) {
|
||||
isLoading = true;
|
||||
|
||||
emit((state as ChatRoomStateLoaded).copyWith(isLoading: isLoading));
|
||||
_emitLoaded((current) => current.copyWith(isLoading: isLoading));
|
||||
|
||||
try {
|
||||
final chatMessages = await chatRepository.getChatMessages(uuid, currentPage + 1);
|
||||
|
||||
isLoading = false;
|
||||
emit(
|
||||
(state as ChatRoomStateLoaded).copyWith(
|
||||
_emitLoaded(
|
||||
(current) => current.copyWith(
|
||||
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}) {
|
||||
final currentState = state;
|
||||
if (currentState is ChatRoomStateLoaded) {
|
||||
if (cards.where((card) => card.isDefault).isNotEmpty) {
|
||||
emit(currentState.copyWith(hasCard: true));
|
||||
}
|
||||
if (cards.where((card) => card.isDefault).isNotEmpty) {
|
||||
_emitLoaded((current) => current.copyWith(hasCard: true));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,6 +114,9 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
|
||||
throw ArgumentError('initChat requires uuid or orderId');
|
||||
}
|
||||
|
||||
currentPage = 1;
|
||||
isLoading = false;
|
||||
|
||||
final chatRoom = await chatRepository.getChatRoom(resolvedUuid);
|
||||
ChosenPlanModel? chosenPlan;
|
||||
final planId = resolvedChosenPlanId ?? chatRoom.chosenPlanId;
|
||||
@@ -135,6 +135,7 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
|
||||
hasCard: false,
|
||||
),
|
||||
);
|
||||
await _subscribeToPresenceChannel(chatId: chatRoom.id);
|
||||
|
||||
if (chosenPlan?.consultantId == chatRoom.users.sender.id) {
|
||||
await checkCards();
|
||||
@@ -154,13 +155,15 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
|
||||
Future<void> updateChosenPlanState() async {
|
||||
try {
|
||||
final currentState = state;
|
||||
if (currentState is ChatRoomStateLoaded) {
|
||||
final chosenPlanId = currentState.chatRoom.chosenPlanId;
|
||||
if (chosenPlanId != null) {
|
||||
final chosenPlan = await chatRepository.getChosenPlan(id: chosenPlanId);
|
||||
emit(currentState.copyWith.chatRoom(chosenPlan: chosenPlan));
|
||||
}
|
||||
if (currentState is! ChatRoomStateLoaded) {
|
||||
return;
|
||||
}
|
||||
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) {
|
||||
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 {
|
||||
final client = wsManager.client;
|
||||
if (client == null) return;
|
||||
_activeChatUuid = uuid;
|
||||
await messagesSubscription?.cancel();
|
||||
await typingSubscription?.cancel();
|
||||
messagesSubscription = null;
|
||||
typingSubscription = null;
|
||||
await wsManager.initPrivateDirectChatChannel(chatId);
|
||||
wsManager.subscribeDirectChatChannel();
|
||||
await _subscribeToPresenceChannel(chatId: chatId);
|
||||
messagesSubscription = wsManager.directChatStream?.listen((event) {
|
||||
AudioPlayer().play((AssetSource('audio/recive_message.wav')));
|
||||
final message = ChatMessageModel.fromJson(jsonDecode(event.data));
|
||||
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());
|
||||
});
|
||||
messagesSubscription = wsManager.directChatStream?.listen(
|
||||
_onDirectChatEvent,
|
||||
onError: (Object e, StackTrace st) {
|
||||
unawaited(reportFrom(e, st, this));
|
||||
},
|
||||
);
|
||||
|
||||
typingSubscription = wsManager.clientTypingStream?.listen((event) {
|
||||
emit((state as ChatRoomStateLoaded).copyWith(isTyping: true, isLoading: isLoading));
|
||||
_emitLoaded((current) => current.copyWith(isTyping: true, isLoading: isLoading));
|
||||
EasyDebounce.debounce(
|
||||
'client-typing',
|
||||
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 {
|
||||
final currentState = state;
|
||||
if (currentState is! ChatRoomStateLoaded) return;
|
||||
@@ -341,59 +398,38 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
|
||||
try {
|
||||
final messageUuid = uuid.v4();
|
||||
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
|
||||
emit(
|
||||
(state as ChatRoomStateLoaded).copyWith(
|
||||
_emitLoaded(
|
||||
(current) => current.copyWith(
|
||||
chatMessages: [
|
||||
ChatMessageModel(
|
||||
id: 1,
|
||||
id: -messageUuid.hashCode.abs(),
|
||||
data: data ?? '',
|
||||
mediaData: compressedFiles,
|
||||
authorId: 1,
|
||||
authorId: wsManager.userId ?? 0,
|
||||
updatedAt: DateTime.now(),
|
||||
isRead: true,
|
||||
isCurrentUserAuthor: true,
|
||||
roomId: '',
|
||||
roomId: current.chatRoom.id,
|
||||
messageState: MessageState.loading,
|
||||
uuid: messageUuid,
|
||||
),
|
||||
...(state as ChatRoomStateLoaded).chatMessages,
|
||||
...current.chatMessages,
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
try {
|
||||
// Отправляем сообщение и получаем ответ от сервера с правильными данными
|
||||
final serverMessage = await chatRepository.sendMessage(data: data, files: compressedFiles, slug: userSlug);
|
||||
|
||||
// Заменяем локальное сообщение данными с сервера (с правильными 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(),
|
||||
),
|
||||
);
|
||||
_replaceOptimisticMessage(messageUuid: messageUuid, serverMessage: serverMessage);
|
||||
} catch (e, st) {
|
||||
await reportFrom(e, st, this);
|
||||
emit(
|
||||
(state as ChatRoomStateLoaded).copyWith(
|
||||
chatMessages: (state as ChatRoomStateLoaded).chatMessages.map((elem) {
|
||||
_emitLoaded(
|
||||
(current) => current.copyWith(
|
||||
chatMessages: current.chatMessages.map((elem) {
|
||||
if (elem.uuid == messageUuid) {
|
||||
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 {
|
||||
if (state is ChatRoomStateLoaded) {
|
||||
final ids = messagesIds.map((e) => e.id).toList();
|
||||
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;
|
||||
if (state is! ChatRoomStateLoaded) {
|
||||
return false;
|
||||
}
|
||||
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) {
|
||||
await reportFrom(e, st, this);
|
||||
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 {
|
||||
try {
|
||||
final planId = (state as ChatRoomStateLoaded).chatRoom.chosenPlan?.id;
|
||||
@@ -477,14 +542,16 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
|
||||
|
||||
@override
|
||||
Future<void> close() async {
|
||||
EasyDebounce.cancel('client-typing');
|
||||
EasyDebounce.cancel('searchChatRoom');
|
||||
if (_presenceOnlineListener != null) {
|
||||
wsManager.removePresenceDirectChatListener(_presenceOnlineListener!);
|
||||
_presenceOnlineListener = null;
|
||||
}
|
||||
wsManager.unsubscribeDirectChatChannel();
|
||||
_timer?.cancel();
|
||||
messagesSubscription?.cancel();
|
||||
typingSubscription?.cancel();
|
||||
await messagesSubscription?.cancel();
|
||||
await typingSubscription?.cancel();
|
||||
super.close();
|
||||
}
|
||||
|
||||
@@ -501,67 +568,46 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
|
||||
final message = currentState.chatMessages.firstWhereOrNull((message) => message.uuid == uuid);
|
||||
if (message != null) {
|
||||
try {
|
||||
AudioPlayer().play((AssetSource('audio/send_message.wav')));
|
||||
try {
|
||||
unawaited(AudioPlayer().play(AssetSource('audio/send_message.wav')));
|
||||
} catch (_) {}
|
||||
|
||||
// Определяем, есть ли файлы для повторной отправки
|
||||
List<File>? filesToResend;
|
||||
if (message.mediaData != null && message.mediaData!.isNotEmpty) {
|
||||
// Если mediaData содержит File объекты, используем их
|
||||
// Если это строки (URL), то файлы уже на сервере и повторная отправка не нужна
|
||||
final firstItem = message.mediaData!.first;
|
||||
if (firstItem is File) {
|
||||
filesToResend = message.mediaData!.cast<File>();
|
||||
}
|
||||
}
|
||||
|
||||
emit(
|
||||
(state as ChatRoomStateLoaded).copyWith(
|
||||
_emitLoaded(
|
||||
(current) => current.copyWith(
|
||||
chatMessages: [
|
||||
ChatMessageModel(
|
||||
id: 1,
|
||||
id: -uuid.hashCode.abs(),
|
||||
data: message.data,
|
||||
mediaData: message.mediaData,
|
||||
authorId: 1,
|
||||
authorId: wsManager.userId ?? 0,
|
||||
updatedAt: DateTime.now(),
|
||||
isRead: true,
|
||||
isCurrentUserAuthor: true,
|
||||
roomId: '',
|
||||
roomId: current.chatRoom.id,
|
||||
messageState: MessageState.loading,
|
||||
uuid: uuid,
|
||||
),
|
||||
...(state as ChatRoomStateLoaded).chatMessages.where((message) => message.uuid != uuid),
|
||||
...current.chatMessages.where((item) => item.uuid != uuid),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
try {
|
||||
// Отправляем сообщение и получаем ответ от сервера
|
||||
final serverMessage = await chatRepository.sendMessage(data: message.data, files: filesToResend, slug: userSlug);
|
||||
|
||||
// Заменяем локальное сообщение данными с сервера
|
||||
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(),
|
||||
),
|
||||
);
|
||||
_replaceOptimisticMessage(messageUuid: uuid, serverMessage: serverMessage);
|
||||
} catch (e, st) {
|
||||
await reportFrom(e, st, this);
|
||||
emit(
|
||||
(state as ChatRoomStateLoaded).copyWith(
|
||||
chatMessages: (state as ChatRoomStateLoaded).chatMessages.map((elem) {
|
||||
_emitLoaded(
|
||||
(current) => current.copyWith(
|
||||
chatMessages: current.chatMessages.map((elem) {
|
||||
if (elem.uuid == uuid) {
|
||||
return elem.copyWith(messageState: MessageState.error);
|
||||
}
|
||||
|
||||
@@ -141,8 +141,8 @@ class ChatsCubit extends Cubit<ChatsState> {
|
||||
|
||||
Future<void> _handleNewMessageViaSocket(dynamic data) async {
|
||||
try {
|
||||
final json = jsonDecode(data);
|
||||
final message = ChatMessageModel.fromJson(json['message']);
|
||||
final json = jsonDecode(data) as Map<String, dynamic>;
|
||||
final message = ChatMessageModel.fromJson(json);
|
||||
|
||||
final currentChatRooms = [...state.chatRooms];
|
||||
|
||||
|
||||
@@ -56,8 +56,7 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
final scrollController = ScrollController();
|
||||
final TextEditingController textEditingController = TextEditingController();
|
||||
final ChatRoomCubit chatRoomCubit = sl<ChatRoomCubit>();
|
||||
final List<({int id, int index})> unreadMessagesIds = [];
|
||||
final List<({int id, int index})> unreadMessagesIdsCopy = [];
|
||||
final List<int> unreadMessageIds = [];
|
||||
final showableChatBadgeStates = ChosenPlanStateEnum.getShowableChatBadgeStates();
|
||||
late AppLifecycleListener _listener;
|
||||
List<File> pickedFiles = [];
|
||||
@@ -116,6 +115,7 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
EasyDebounce.cancel('mark_as_read');
|
||||
scrollController.dispose();
|
||||
_listener.dispose();
|
||||
chatRoomCubit.close();
|
||||
@@ -337,21 +337,15 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
isCurrentUser: isCurrentUserAuthor,
|
||||
),
|
||||
VisibilityDetector(
|
||||
key: Key(message.id.toString()),
|
||||
key: ValueKey('msg-${message.uuid ?? message.id}'),
|
||||
onVisibilityChanged: (VisibilityInfo info) {
|
||||
if (!message.isRead && !isCurrentUserAuthor && info.visibleFraction > 0) {
|
||||
unreadMessagesIdsCopy.add((id: message.id, index: index));
|
||||
EasyDebounce.debounce('mark_as_read', const Duration(milliseconds: 500), () async {
|
||||
final result = await context.read<ChatRoomCubit>().markMessagesAsRead(state.chatRoom.id, unreadMessagesIdsCopy);
|
||||
if (!result) return;
|
||||
if (!context.mounted) return;
|
||||
context.read<ChatsCubit>().reduceUnreadMessagesCount(
|
||||
chatId: state.chatRoom.id,
|
||||
count: unreadMessagesIdsCopy.length,
|
||||
);
|
||||
unreadMessagesIdsCopy.clear();
|
||||
});
|
||||
}
|
||||
_queueMarkAsRead(
|
||||
context: context,
|
||||
chatId: state.chatRoom.id,
|
||||
message: message,
|
||||
isCurrentUserAuthor: isCurrentUserAuthor,
|
||||
visibleFraction: info.visibleFraction,
|
||||
);
|
||||
},
|
||||
child: _buildMessageBubble(
|
||||
context: context,
|
||||
@@ -391,18 +385,15 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
isCurrentUser: isCurrentUserAuthor,
|
||||
),
|
||||
VisibilityDetector(
|
||||
key: Key(message.id.toString()),
|
||||
key: ValueKey('msg-${message.uuid ?? message.id}'),
|
||||
onVisibilityChanged: (VisibilityInfo info) {
|
||||
if (!message.isRead && !isCurrentUserAuthor && info.visibleFraction > 0) {
|
||||
unreadMessagesIds.add((id: message.id, index: index));
|
||||
EasyDebounce.debounce('mark_as_read', const Duration(milliseconds: 500), () async {
|
||||
final result = await context.read<ChatRoomCubit>().markMessagesAsRead(state.chatRoom.id, unreadMessagesIds);
|
||||
if (!result) return;
|
||||
if (!context.mounted) return;
|
||||
context.read<ChatsCubit>().reduceUnreadMessagesCount(chatId: state.chatRoom.id, count: unreadMessagesIds.length);
|
||||
unreadMessagesIds.clear();
|
||||
});
|
||||
}
|
||||
_queueMarkAsRead(
|
||||
context: context,
|
||||
chatId: state.chatRoom.id,
|
||||
message: message,
|
||||
isCurrentUserAuthor: isCurrentUserAuthor,
|
||||
visibleFraction: info.visibleFraction,
|
||||
);
|
||||
},
|
||||
child: _buildMessageBubble(
|
||||
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({
|
||||
required BuildContext context,
|
||||
required ChatRoomStateLoaded state,
|
||||
|
||||
@@ -33,9 +33,7 @@ class _ComplainingBottomSheetState extends State<ComplainingBottomSheet> {
|
||||
create: (context) => sl<ComplainingCubit>()..setContentTypeAndIdentifier(contentType: widget.complainedContentType, slug: widget.slug, id: widget.id),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.only(top: 8, bottom: 24, left: 16, right: 16),
|
||||
decoration: const BoxDecoration(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(12)),
|
||||
),
|
||||
decoration: const BoxDecoration(borderRadius: BorderRadius.vertical(top: Radius.circular(12))),
|
||||
child: BlocBuilder<ComplainingCubit, ComplainingState>(
|
||||
builder: (context, state) {
|
||||
final category = state.category;
|
||||
@@ -72,10 +70,7 @@ class _CategoryWidget extends StatelessWidget {
|
||||
children: [
|
||||
const BottomSheetHandlerBar(),
|
||||
const SizedBox(height: 20),
|
||||
Text(
|
||||
s.complaint,
|
||||
style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black),
|
||||
),
|
||||
Text(s.complaint, style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black)),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
s.complaint_question,
|
||||
@@ -114,10 +109,7 @@ class _SpamWidget extends StatelessWidget {
|
||||
children: [
|
||||
const BottomSheetHandlerBar(),
|
||||
const SizedBox(height: 20),
|
||||
Text(
|
||||
s.spam,
|
||||
style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black),
|
||||
),
|
||||
Text(s.spam, style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black)),
|
||||
const SizedBox(height: 8),
|
||||
SizedBox(
|
||||
width: MediaQuery.sizeOf(context).width * 0.7,
|
||||
@@ -145,10 +137,7 @@ class _ExplicitImageWidget extends StatelessWidget {
|
||||
children: [
|
||||
const BottomSheetHandlerBar(),
|
||||
const SizedBox(height: 20),
|
||||
Text(
|
||||
s.explicitImages,
|
||||
style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black),
|
||||
),
|
||||
Text(s.explicitImages, style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black)),
|
||||
const SizedBox(height: 8),
|
||||
SizedBox(
|
||||
width: MediaQuery.sizeOf(context).width * 0.7,
|
||||
@@ -202,10 +191,7 @@ class _LawViolationWidgetState extends State<_LawViolationWidget> {
|
||||
children: [
|
||||
const BottomSheetHandlerBar(),
|
||||
const SizedBox(height: 20),
|
||||
Text(
|
||||
s.lawViolation,
|
||||
style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black),
|
||||
),
|
||||
Text(s.lawViolation, style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black)),
|
||||
const SizedBox(height: 8),
|
||||
SizedBox(
|
||||
width: MediaQuery.sizeOf(context).width * 0.7,
|
||||
@@ -235,10 +221,7 @@ class _LawViolationWidgetState extends State<_LawViolationWidget> {
|
||||
});
|
||||
},
|
||||
),
|
||||
Text(
|
||||
element.$2,
|
||||
style: TextStyles.textLSemibold.copyWith(color: appTheme.black),
|
||||
),
|
||||
Text(element.$2, style: TextStyles.textLSemibold.copyWith(color: appTheme.black)),
|
||||
],
|
||||
);
|
||||
}),
|
||||
@@ -254,12 +237,10 @@ class _LawViolationWidgetState extends State<_LawViolationWidget> {
|
||||
if ((state.complainInfo.description?.length ?? 10) < 10) ...[
|
||||
const SizedBox(height: 4),
|
||||
const ValidationErrorWidget(error: 'Минимум 10 символов'),
|
||||
]
|
||||
],
|
||||
],
|
||||
const SizedBox(height: 20),
|
||||
_ComplainButton(
|
||||
isDisabled: (state.complainInfo.description?.length ?? 0) < 10,
|
||||
),
|
||||
_ComplainButton(isDisabled: tags.firstWhere((tag) => tag.$2 == s.other).$1 && (state.complainInfo.description?.length ?? 0) < 10),
|
||||
],
|
||||
);
|
||||
},
|
||||
@@ -301,10 +282,7 @@ class _ViolenceAndHostilityState extends State<_ViolenceAndHostility> {
|
||||
children: [
|
||||
const BottomSheetHandlerBar(),
|
||||
const SizedBox(height: 20),
|
||||
Text(
|
||||
s.lawViolation,
|
||||
style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black),
|
||||
),
|
||||
Text(s.lawViolation, style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black)),
|
||||
const SizedBox(height: 8),
|
||||
SizedBox(
|
||||
width: MediaQuery.sizeOf(context).width * 0.7,
|
||||
@@ -332,10 +310,7 @@ class _ViolenceAndHostilityState extends State<_ViolenceAndHostility> {
|
||||
},
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
element.$2,
|
||||
style: TextStyles.textLSemibold.copyWith(color: appTheme.black),
|
||||
),
|
||||
child: Text(element.$2, style: TextStyles.textLSemibold.copyWith(color: appTheme.black)),
|
||||
),
|
||||
],
|
||||
);
|
||||
@@ -363,15 +338,9 @@ class _DeceptionWidgetState extends State<_DeceptionWidget> {
|
||||
super.didChangeDependencies();
|
||||
final s = S.of(context);
|
||||
if (tags.isEmpty && descriptions.isEmpty) {
|
||||
tags.addAll([
|
||||
(false, s.misrepresentation),
|
||||
(false, s.fraud),
|
||||
]);
|
||||
tags.addAll([(false, s.misrepresentation), (false, s.fraud)]);
|
||||
|
||||
descriptions.addAll([
|
||||
s.misrepresentation_description,
|
||||
s.fraud_description,
|
||||
]);
|
||||
descriptions.addAll([s.misrepresentation_description, s.fraud_description]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -383,10 +352,7 @@ class _DeceptionWidgetState extends State<_DeceptionWidget> {
|
||||
children: [
|
||||
const BottomSheetHandlerBar(),
|
||||
const SizedBox(height: 20),
|
||||
Text(
|
||||
s.lawViolation,
|
||||
style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black),
|
||||
),
|
||||
Text(s.lawViolation, style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black)),
|
||||
const SizedBox(height: 8),
|
||||
SizedBox(
|
||||
width: MediaQuery.sizeOf(context).width * 0.7,
|
||||
@@ -419,16 +385,10 @@ class _DeceptionWidgetState extends State<_DeceptionWidget> {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
element.$2,
|
||||
style: TextStyles.textLSemibold.copyWith(color: appTheme.black),
|
||||
),
|
||||
Text(element.$2, style: TextStyles.textLSemibold.copyWith(color: appTheme.black)),
|
||||
SizedBox(
|
||||
width: MediaQuery.sizeOf(context).width * 0.7,
|
||||
child: Text(
|
||||
descriptions[index],
|
||||
style: TextStyles.textSSemibold.copyWith(color: appTheme.lightGray600),
|
||||
),
|
||||
child: Text(descriptions[index], style: TextStyles.textSSemibold.copyWith(color: appTheme.lightGray600)),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -453,10 +413,7 @@ class _ComplaintSentWidget extends StatelessWidget {
|
||||
children: [
|
||||
const BottomSheetHandlerBar(),
|
||||
const SizedBox(height: 20),
|
||||
Text(
|
||||
s.complaint_sent,
|
||||
style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black),
|
||||
),
|
||||
Text(s.complaint_sent, style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black)),
|
||||
const SizedBox(height: 8),
|
||||
SizedBox(
|
||||
width: MediaQuery.sizeOf(context).width * 0.7,
|
||||
@@ -471,10 +428,7 @@ class _ComplaintSentWidget extends StatelessWidget {
|
||||
text: s.ok,
|
||||
height: 52,
|
||||
buttonTextStyle: TextStyles.textMSemibold.copyWith(color: appTheme.whiteA700),
|
||||
buttonStyle: ButtonStyle(
|
||||
side: const WidgetStatePropertyAll(BorderSide.none),
|
||||
backgroundColor: WidgetStatePropertyAll(appTheme.blueA400),
|
||||
),
|
||||
buttonStyle: ButtonStyle(side: const WidgetStatePropertyAll(BorderSide.none), backgroundColor: WidgetStatePropertyAll(appTheme.blueA400)),
|
||||
onPressed: () {
|
||||
AppRouter.navigatorKey.currentContext?.pop();
|
||||
},
|
||||
@@ -495,10 +449,7 @@ class _ComplaintFailedWidget extends StatelessWidget {
|
||||
children: [
|
||||
const BottomSheetHandlerBar(),
|
||||
const SizedBox(height: 20),
|
||||
Text(
|
||||
s.could_not_send_complain,
|
||||
style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black),
|
||||
),
|
||||
Text(s.could_not_send_complain, style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black)),
|
||||
const SizedBox(height: 8),
|
||||
SizedBox(
|
||||
width: MediaQuery.sizeOf(context).width * 0.7,
|
||||
@@ -513,10 +464,7 @@ class _ComplaintFailedWidget extends StatelessWidget {
|
||||
text: s.ok,
|
||||
height: 52,
|
||||
buttonTextStyle: TextStyles.textMSemibold.copyWith(color: appTheme.whiteA700),
|
||||
buttonStyle: ButtonStyle(
|
||||
side: const WidgetStatePropertyAll(BorderSide.none),
|
||||
backgroundColor: WidgetStatePropertyAll(appTheme.blueA400),
|
||||
),
|
||||
buttonStyle: ButtonStyle(side: const WidgetStatePropertyAll(BorderSide.none), backgroundColor: WidgetStatePropertyAll(appTheme.blueA400)),
|
||||
onPressed: () {
|
||||
AppRouter.navigatorKey.currentContext?.pop();
|
||||
},
|
||||
@@ -535,16 +483,13 @@ class _ComplainButton extends StatelessWidget {
|
||||
return BlocBuilder<ComplainingCubit, ComplainingState>(
|
||||
builder: (context, state) {
|
||||
if (state.isLoading) {
|
||||
return const Loader();
|
||||
return const SizedBox(height: 52, child: Loader());
|
||||
}
|
||||
return CustomOutlinedButton(
|
||||
text: s.send_complain,
|
||||
height: 52,
|
||||
buttonTextStyle: TextStyles.textMSemibold.copyWith(color: appTheme.whiteA700),
|
||||
buttonStyle: ButtonStyle(
|
||||
side: const WidgetStatePropertyAll(BorderSide.none),
|
||||
backgroundColor: WidgetStatePropertyAll(appTheme.blueA400),
|
||||
),
|
||||
buttonStyle: ButtonStyle(side: const WidgetStatePropertyAll(BorderSide.none), backgroundColor: WidgetStatePropertyAll(appTheme.blueA400)),
|
||||
onPressed: () async {
|
||||
if (isDisabled) {
|
||||
context.read<ComplainingCubit>().setDescription('');
|
||||
@@ -570,10 +515,7 @@ class _CategoryButton extends StatelessWidget {
|
||||
child: Ink(
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
category.getTranslatedText(),
|
||||
style: TextStyles.textLSemibold.copyWith(color: appTheme.black),
|
||||
),
|
||||
Text(category.getTranslatedText(), style: TextStyles.textLSemibold.copyWith(color: appTheme.black)),
|
||||
const Spacer(),
|
||||
SvgPicture.asset('assets/icons/right_arrow.svg'),
|
||||
],
|
||||
@@ -591,18 +533,18 @@ enum ComplainType {
|
||||
explicitImage;
|
||||
|
||||
String getTranslatedText() => switch (this) {
|
||||
ComplainType.spam => S.current.spam,
|
||||
ComplainType.lawViolation => S.current.lawViolation,
|
||||
ComplainType.deception => S.current.deception,
|
||||
ComplainType.violenceDndHostility => S.current.violenceAndHostility,
|
||||
ComplainType.explicitImage => S.current.explicitImage,
|
||||
};
|
||||
ComplainType.spam => S.current.spam,
|
||||
ComplainType.lawViolation => S.current.lawViolation,
|
||||
ComplainType.deception => S.current.deception,
|
||||
ComplainType.violenceDndHostility => S.current.violenceAndHostility,
|
||||
ComplainType.explicitImage => S.current.explicitImage,
|
||||
};
|
||||
|
||||
String getValue() => switch (this) {
|
||||
ComplainType.spam => 'spam',
|
||||
ComplainType.lawViolation => 'legalViolation',
|
||||
ComplainType.deception => 'fraud',
|
||||
ComplainType.violenceDndHostility => 'violenceAndHatred',
|
||||
ComplainType.explicitImage => 'explicitImage',
|
||||
};
|
||||
ComplainType.spam => 'spam',
|
||||
ComplainType.lawViolation => 'legalViolation',
|
||||
ComplainType.deception => 'fraud',
|
||||
ComplainType.violenceDndHostility => 'violenceAndHatred',
|
||||
ComplainType.explicitImage => 'explicitImage',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -36,6 +36,10 @@ class ComplainingCubit extends Cubit<ComplainingState> {
|
||||
}
|
||||
|
||||
Future<void> complain() async {
|
||||
if (state.isLoading) {
|
||||
return;
|
||||
}
|
||||
emit(state.copyWith(isLoading: true));
|
||||
try {
|
||||
final category = state.category;
|
||||
final id = state.contentId;
|
||||
@@ -46,37 +50,37 @@ class ComplainingCubit extends Cubit<ComplainingState> {
|
||||
case ComplainedContent.diet:
|
||||
if (id != null) {
|
||||
await repository.sendDietComplain(id: id, category: category.getValue(), body: state.complainInfo);
|
||||
emit(state.copyWith(wasSentSuccessfully: true));
|
||||
emit(state.copyWith(wasSentSuccessfully: true, isLoading: false));
|
||||
return;
|
||||
}
|
||||
case ComplainedContent.plan:
|
||||
if (slug != null) {
|
||||
await repository.sendPlanComplain(slug: slug, category: category.getValue(), body: state.complainInfo);
|
||||
emit(state.copyWith(wasSentSuccessfully: true));
|
||||
emit(state.copyWith(wasSentSuccessfully: true, isLoading: false));
|
||||
return;
|
||||
}
|
||||
case ComplainedContent.recipe:
|
||||
if (slug != null) {
|
||||
await repository.sendRecipeComplain(slug: slug, category: category.getValue(), body: state.complainInfo);
|
||||
emit(state.copyWith(wasSentSuccessfully: true));
|
||||
emit(state.copyWith(wasSentSuccessfully: true, isLoading: false));
|
||||
return;
|
||||
}
|
||||
case ComplainedContent.profile:
|
||||
if (slug != null) {
|
||||
await repository.sendProfileComplain(slug: slug, category: category.getValue(), body: state.complainInfo);
|
||||
emit(state.copyWith(wasSentSuccessfully: true));
|
||||
emit(state.copyWith(wasSentSuccessfully: true, isLoading: false));
|
||||
return;
|
||||
}
|
||||
case ComplainedContent.posts:
|
||||
if (id != null) {
|
||||
await repository.sendBlogPostsComplain(id: id, category: category.getValue(), body: state.complainInfo);
|
||||
emit(state.copyWith(wasSentSuccessfully: true));
|
||||
emit(state.copyWith(wasSentSuccessfully: true, isLoading: false));
|
||||
return;
|
||||
}
|
||||
case ComplainedContent.comment:
|
||||
if (id != null) {
|
||||
await repository.sendCommentComplain(id: id, category: category.getValue(), body: state.complainInfo);
|
||||
emit(state.copyWith(wasSentSuccessfully: true));
|
||||
emit(state.copyWith(wasSentSuccessfully: true, isLoading: false));
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -85,6 +89,6 @@ class ComplainingCubit extends Cubit<ComplainingState> {
|
||||
await reportFrom(e, st, this);
|
||||
log(e.toString(), stackTrace: st);
|
||||
}
|
||||
emit(state.copyWith(wasSentSuccessfully: false));
|
||||
emit(state.copyWith(wasSentSuccessfully: false, isLoading: false));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'package:eatvio_app/features/consultant/become_consultant/utils/passport_data_validator.dart';
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
part 'consultant_profile_model.freezed.dart';
|
||||
@@ -54,37 +55,7 @@ abstract class ConsultantProfileModel with _$ConsultantProfileModel {
|
||||
factory ConsultantProfileModel.fromJson(Map<String, Object?> json) =>
|
||||
_$ConsultantProfileModelFromJson(json);
|
||||
|
||||
bool get hasValidPassportData {
|
||||
final last = lastName ?? '';
|
||||
final first = firstName ?? '';
|
||||
final innValue = inn ?? '';
|
||||
final snilsValue = snils ?? '';
|
||||
final series = passportSeries ?? '';
|
||||
final number = passportNumber ?? '';
|
||||
final issueDate = passportIssueDate ?? '';
|
||||
final issuer = passportIssuer ?? '';
|
||||
final issuerCode = passportIssuerCode ?? '';
|
||||
final birth = birthDate ?? '';
|
||||
final birthPlaceValue = birthPlace ?? '';
|
||||
final registration = registrationAddress ?? '';
|
||||
final residence = residenceAddress ?? '';
|
||||
final citizenshipValue = citizenship ?? '';
|
||||
|
||||
return last.trim().isNotEmpty &&
|
||||
first.trim().isNotEmpty &&
|
||||
RegExp(r'^\d{12}$').hasMatch(innValue) &&
|
||||
RegExp(r'^\d{3}-\d{3}-\d{3} \d{2}$').hasMatch(snilsValue) &&
|
||||
RegExp(r'^\d{4}$').hasMatch(series) &&
|
||||
RegExp(r'^\d{6}$').hasMatch(number) &&
|
||||
RegExp(r'^\d{4}-\d{2}-\d{2}$').hasMatch(issueDate) &&
|
||||
issuer.trim().isNotEmpty &&
|
||||
RegExp(r'^\d{3}-\d{3}$').hasMatch(issuerCode) &&
|
||||
RegExp(r'^\d{4}-\d{2}-\d{2}$').hasMatch(birth) &&
|
||||
birthPlaceValue.trim().isNotEmpty &&
|
||||
registration.trim().isNotEmpty &&
|
||||
residence.trim().isNotEmpty &&
|
||||
RegExp(r'^[A-Za-z]{2}$').hasMatch(citizenshipValue);
|
||||
}
|
||||
bool get hasValidPassportData => PassportDataValidator.validate(this).isEmpty;
|
||||
}
|
||||
|
||||
@freezed
|
||||
|
||||
@@ -12,6 +12,7 @@ import 'package:eatvio_app/features/consultant/become_consultant/models/sbp_bank
|
||||
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/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';
|
||||
|
||||
class RegisterConsultantCubit extends Cubit<RegisterConsultantState> {
|
||||
@@ -240,6 +241,9 @@ class RegisterConsultantCubit extends Cubit<RegisterConsultantState> {
|
||||
bool? acceptOffer,
|
||||
bool? acceptFrdoAgreement,
|
||||
]) {
|
||||
final nextFieldErrors = state.fieldErrors.isEmpty
|
||||
? const <String, String>{}
|
||||
: PassportDataValidator.validate(consultant);
|
||||
emit(
|
||||
state.copyWith(
|
||||
consultant: consultant,
|
||||
@@ -247,11 +251,17 @@ class RegisterConsultantCubit extends Cubit<RegisterConsultantState> {
|
||||
acceptFrdoAgreement: acceptFrdoAgreement ?? state.acceptFrdoAgreement,
|
||||
loading: false,
|
||||
error: null,
|
||||
fieldErrors: const <String, String>{},
|
||||
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(
|
||||
@@ -322,8 +332,8 @@ class RegisterConsultantCubit extends Cubit<RegisterConsultantState> {
|
||||
}
|
||||
|
||||
Future<void> registerConsultant() async {
|
||||
if (!state.consultant.hasValidPassportData) {
|
||||
emit(state.copyWith(error: 'Заполните паспортные данные'));
|
||||
final passportErrors = validatePassportFields();
|
||||
if (passportErrors.isNotEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -401,6 +411,10 @@ class RegisterConsultantCubit extends Cubit<RegisterConsultantState> {
|
||||
if (!state.alfaScoringStatus.canRetry || state.scoringRetryInProgress) {
|
||||
return;
|
||||
}
|
||||
final passportErrors = validatePassportFields();
|
||||
if (passportErrors.isNotEmpty) {
|
||||
return;
|
||||
}
|
||||
emit(
|
||||
state.copyWith(
|
||||
scoringRetryInProgress: true,
|
||||
@@ -413,18 +427,6 @@ class RegisterConsultantCubit extends Cubit<RegisterConsultantState> {
|
||||
);
|
||||
_update = true;
|
||||
try {
|
||||
if (!state.consultant.hasValidPassportData) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
scoringRetryInProgress: false,
|
||||
isScoringPolling: false,
|
||||
awaitingScoringOutcome: false,
|
||||
alfaScoringStatus: AlfaScoringStatus.scoringFailed,
|
||||
error: 'Заполните паспортные данные',
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
await consultantRepository.registerConsultant(
|
||||
consultant: buildSubmitPayload(),
|
||||
update: true,
|
||||
|
||||
@@ -7,6 +7,7 @@ import 'package:eatvio_app/features/consultant/become_consultant/presentation/bl
|
||||
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';
|
||||
@@ -68,6 +69,9 @@ class _PassportDataScreenState extends State<PassportDataScreen> {
|
||||
filter: {'#': RegExp(r'[0-9]')},
|
||||
type: MaskAutoCompletionType.lazy,
|
||||
);
|
||||
final _alfaPersonNameFormatter = FilteringTextInputFormatter.allow(
|
||||
RegExp(r'[a-zA-Zа-яА-ЯёЁ ,.-]'),
|
||||
);
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -300,6 +304,8 @@ class _PassportDataScreenState extends State<PassportDataScreen> {
|
||||
child: CustomTextFormField(
|
||||
hintText: 'Фамилия',
|
||||
controller: lastNameController,
|
||||
maxLength: 50,
|
||||
inputFormatters: [_alfaPersonNameFormatter],
|
||||
hasError: errors.containsKey('last_name'),
|
||||
onChanged: (_) => _syncToCubit(),
|
||||
),
|
||||
@@ -310,6 +316,8 @@ class _PassportDataScreenState extends State<PassportDataScreen> {
|
||||
child: CustomTextFormField(
|
||||
hintText: 'Имя',
|
||||
controller: firstNameController,
|
||||
maxLength: 50,
|
||||
inputFormatters: [_alfaPersonNameFormatter],
|
||||
hasError: errors.containsKey('first_name'),
|
||||
onChanged: (_) => _syncToCubit(),
|
||||
),
|
||||
@@ -320,6 +328,8 @@ class _PassportDataScreenState extends State<PassportDataScreen> {
|
||||
child: CustomTextFormField(
|
||||
hintText: 'Отчество',
|
||||
controller: middleNameController,
|
||||
maxLength: 50,
|
||||
inputFormatters: [_alfaPersonNameFormatter],
|
||||
hasError: errors.containsKey('middle_name'),
|
||||
onChanged: (_) => _syncToCubit(),
|
||||
),
|
||||
@@ -332,6 +342,7 @@ class _PassportDataScreenState extends State<PassportDataScreen> {
|
||||
controller: innController,
|
||||
keyboardType: TextInputType.number,
|
||||
maxLength: 12,
|
||||
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
|
||||
hasError: errors.containsKey('inn'),
|
||||
onChanged: (_) => _syncToCubit(),
|
||||
),
|
||||
@@ -446,6 +457,9 @@ class _PassportDataScreenState extends State<PassportDataScreen> {
|
||||
controller: seriesController,
|
||||
keyboardType: TextInputType.number,
|
||||
maxLength: 4,
|
||||
inputFormatters: [
|
||||
FilteringTextInputFormatter.digitsOnly,
|
||||
],
|
||||
hasError: errors.containsKey('passport_series'),
|
||||
onChanged: (_) => _syncToCubit(),
|
||||
),
|
||||
@@ -460,6 +474,9 @@ class _PassportDataScreenState extends State<PassportDataScreen> {
|
||||
controller: numberController,
|
||||
keyboardType: TextInputType.number,
|
||||
maxLength: 6,
|
||||
inputFormatters: [
|
||||
FilteringTextInputFormatter.digitsOnly,
|
||||
],
|
||||
hasError: errors.containsKey('passport_number'),
|
||||
onChanged: (_) => _syncToCubit(),
|
||||
),
|
||||
|
||||
@@ -146,212 +146,230 @@ class _StepperBecomeConsultantScreenState
|
||||
Widget build(BuildContext context) {
|
||||
return BlocProvider<RegisterConsultantCubit>(
|
||||
create: (context) => registrationConsultantCubit,
|
||||
child: BlocConsumer<RegisterConsultantCubit, RegisterConsultantState>(
|
||||
child: BlocListener<RegisterConsultantCubit, RegisterConsultantState>(
|
||||
listenWhen: (previous, current) =>
|
||||
previous.awaitingScoringOutcome &&
|
||||
!current.awaitingScoringOutcome &&
|
||||
current.alfaScoringStatus.isComplete,
|
||||
previous.fieldErrors.isEmpty &&
|
||||
current.fieldErrors.isNotEmpty &&
|
||||
current.error == null,
|
||||
listener: (context, state) {
|
||||
_showSuccessSheet(context);
|
||||
pageController.jumpToPage(5);
|
||||
},
|
||||
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(
|
||||
floatingActionButtonLocation:
|
||||
FloatingActionButtonLocation.centerFloat,
|
||||
floatingActionButton: Row(
|
||||
children: [
|
||||
Container(
|
||||
height: 48,
|
||||
width: MediaQuery.of(context).size.width * 0.2,
|
||||
padding: const EdgeInsets.only(left: 16, right: 4),
|
||||
child: FloatingActionButton.extended(
|
||||
heroTag: 'backButtonRegistration',
|
||||
highlightElevation: 0,
|
||||
focusElevation: 0,
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
backgroundColor: const Color(0xFFEAF2FD),
|
||||
onPressed: () async {
|
||||
setState(() {
|
||||
pageController.previousPage(
|
||||
duration: const Duration(milliseconds: 350),
|
||||
curve: Curves.easeInOut,
|
||||
);
|
||||
});
|
||||
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');
|
||||
},
|
||||
label: Icon(
|
||||
Icons.arrow_back_ios_new_rounded,
|
||||
color: appTheme.blueA400,
|
||||
),
|
||||
title: Text(
|
||||
'',
|
||||
maxLines: 2,
|
||||
textAlign: TextAlign.center,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
softWrap: true,
|
||||
style: TextStyles.headingXLSemibold.copyWith(
|
||||
color: appTheme.black,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (widget.isCanceled == true && step != totalSteps)
|
||||
InkWell(
|
||||
onTap: () {
|
||||
final canceledModeration =
|
||||
state.requestModel?.canceledModeration;
|
||||
if (canceledModeration == null) return;
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text(canceledModeration.title),
|
||||
content: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (canceledModeration.additional != null)
|
||||
for (final item
|
||||
in canceledModeration.additional!) ...[
|
||||
Text('• $item'),
|
||||
],
|
||||
body: DataErrorWidget(
|
||||
onReload: () => context
|
||||
.read<RegisterConsultantCubit>()
|
||||
.reloadData(widget.slug),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (progressValue == 0) {
|
||||
progressValue = 1 / totalSteps;
|
||||
}
|
||||
|
||||
final submitDisabled =
|
||||
isLoading ||
|
||||
state.isScoringPolling ||
|
||||
state.scoringRetryInProgress;
|
||||
|
||||
return Scaffold(
|
||||
floatingActionButtonLocation:
|
||||
FloatingActionButtonLocation.centerFloat,
|
||||
floatingActionButton: Row(
|
||||
children: [
|
||||
Container(
|
||||
height: 48,
|
||||
width: MediaQuery.of(context).size.width * 0.2,
|
||||
padding: const EdgeInsets.only(left: 16, right: 4),
|
||||
child: FloatingActionButton.extended(
|
||||
heroTag: 'backButtonRegistration',
|
||||
highlightElevation: 0,
|
||||
focusElevation: 0,
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
backgroundColor: const Color(0xFFEAF2FD),
|
||||
onPressed: () async {
|
||||
setState(() {
|
||||
pageController.previousPage(
|
||||
duration: const Duration(milliseconds: 350),
|
||||
curve: Curves.easeInOut,
|
||||
);
|
||||
});
|
||||
},
|
||||
label: Icon(
|
||||
Icons.arrow_back_ios_new_rounded,
|
||||
color: appTheme.blueA400,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (widget.isCanceled == true && step != totalSteps)
|
||||
InkWell(
|
||||
onTap: () {
|
||||
final canceledModeration =
|
||||
state.requestModel?.canceledModeration;
|
||||
if (canceledModeration == null) return;
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text(canceledModeration.title),
|
||||
content: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (canceledModeration.additional != null)
|
||||
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'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
child: Ink(
|
||||
height: 48,
|
||||
width: MediaQuery.of(context).size.width * 0.6,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
color: const Color(0x33FFCC00),
|
||||
),
|
||||
);
|
||||
},
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
child: Ink(
|
||||
height: 48,
|
||||
width: MediaQuery.of(context).size.width * 0.6,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
color: const Color(0x33FFCC00),
|
||||
),
|
||||
child: const Center(
|
||||
child: Text(
|
||||
'Комментарий',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Color(0xFF323F4B),
|
||||
height: 1,
|
||||
child: const Center(
|
||||
child: Text(
|
||||
'Комментарий',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Color(0xFF323F4B),
|
||||
height: 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Container(
|
||||
height: 48,
|
||||
width: widget.isCanceled == true && step != totalSteps
|
||||
? MediaQuery.of(context).size.width * 0.2
|
||||
: MediaQuery.of(context).size.width * 0.8,
|
||||
padding: const EdgeInsets.only(left: 4, right: 16),
|
||||
child: FloatingActionButton.extended(
|
||||
heroTag: 'nextButtonRegistration',
|
||||
highlightElevation: 0,
|
||||
elevation: 0,
|
||||
focusElevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
backgroundColor: submitDisabled
|
||||
? Colors.grey
|
||||
: widget.isCanceled == true && step != totalSteps
|
||||
? const Color(0xFFEAF2FD)
|
||||
: appTheme.blueA400,
|
||||
onPressed: submitDisabled
|
||||
? null
|
||||
: () async {
|
||||
if (step == totalSteps) {
|
||||
if (!state.consultant.hasValidPassportData) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
actionsPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 4,
|
||||
),
|
||||
content: const Text(
|
||||
'Заполните паспортные данные',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
child: const Text('Ок'),
|
||||
onPressed: () =>
|
||||
Navigator.of(context).pop(),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (registrationConsultantCubit
|
||||
.hasAcceptedRequiredAgreements) {
|
||||
setState(() {
|
||||
isLoading = true;
|
||||
});
|
||||
try {
|
||||
await registrationConsultantCubit
|
||||
.registerConsultant();
|
||||
if (!context.mounted) return;
|
||||
if (registrationConsultantCubit.state.error !=
|
||||
null) {
|
||||
showDialog(
|
||||
Container(
|
||||
height: 48,
|
||||
width: widget.isCanceled == true && step != totalSteps
|
||||
? MediaQuery.of(context).size.width * 0.2
|
||||
: MediaQuery.of(context).size.width * 0.8,
|
||||
padding: const EdgeInsets.only(left: 4, right: 16),
|
||||
child: FloatingActionButton.extended(
|
||||
heroTag: 'nextButtonRegistration',
|
||||
highlightElevation: 0,
|
||||
elevation: 0,
|
||||
focusElevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
backgroundColor: submitDisabled
|
||||
? Colors.grey
|
||||
: widget.isCanceled == true && step != totalSteps
|
||||
? const Color(0xFFEAF2FD)
|
||||
: appTheme.blueA400,
|
||||
onPressed: submitDisabled
|
||||
? null
|
||||
: () async {
|
||||
if (step == totalSteps) {
|
||||
final passportErrors =
|
||||
registrationConsultantCubit
|
||||
.validatePassportFields();
|
||||
if (passportErrors.isNotEmpty) {
|
||||
pageController.jumpToPage(5);
|
||||
return;
|
||||
}
|
||||
if (registrationConsultantCubit
|
||||
.hasAcceptedRequiredAgreements) {
|
||||
setState(() {
|
||||
isLoading = true;
|
||||
});
|
||||
try {
|
||||
await registrationConsultantCubit
|
||||
.registerConsultant();
|
||||
if (!context.mounted) return;
|
||||
if (registrationConsultantCubit
|
||||
.state
|
||||
.error !=
|
||||
null) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
actionsPadding:
|
||||
const EdgeInsets.symmetric(
|
||||
horizontal: 4,
|
||||
),
|
||||
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) {
|
||||
if (!context.mounted) return;
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
actionsPadding:
|
||||
const EdgeInsets.symmetric(
|
||||
horizontal: 4,
|
||||
),
|
||||
content: Text(
|
||||
registrationConsultantCubit
|
||||
.state
|
||||
.error!,
|
||||
),
|
||||
content: Text(e.toString()),
|
||||
actions: [
|
||||
TextButton(
|
||||
child: const Text('Ок'),
|
||||
@@ -361,140 +379,120 @@ class _StepperBecomeConsultantScreenState
|
||||
],
|
||||
),
|
||||
);
|
||||
return;
|
||||
} finally {
|
||||
setState(() {
|
||||
isLoading = false;
|
||||
});
|
||||
}
|
||||
// Success sheet is shown by BlocListener when scoring completes.
|
||||
} catch (e) {
|
||||
if (!context.mounted) return;
|
||||
showDialog<void>(
|
||||
} else {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
actionsPadding:
|
||||
const EdgeInsets.symmetric(
|
||||
horizontal: 4,
|
||||
),
|
||||
content: Text(e.toString()),
|
||||
content: Text(
|
||||
state.isUpdating
|
||||
? 'Примите договор публичной оферты'
|
||||
: 'Примите оферту и согласие ФРДО',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
child: const Text('Ок'),
|
||||
onPressed: () =>
|
||||
Navigator.of(context).pop(),
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
setState(() {
|
||||
isLoading = false;
|
||||
});
|
||||
}
|
||||
} else {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
actionsPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 4,
|
||||
),
|
||||
content: Text(
|
||||
state.isUpdating
|
||||
? 'Примите договор публичной оферты'
|
||||
: 'Примите оферту и согласие ФРДО',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
child: const Text('Ок'),
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
FocusManager.instance.primaryFocus?.unfocus();
|
||||
setState(() {
|
||||
pageController.nextPage(
|
||||
duration: const Duration(milliseconds: 350),
|
||||
curve: Curves.easeInOut,
|
||||
);
|
||||
});
|
||||
}
|
||||
} else {
|
||||
FocusManager.instance.primaryFocus?.unfocus();
|
||||
setState(() {
|
||||
pageController.nextPage(
|
||||
duration: const Duration(milliseconds: 350),
|
||||
curve: Curves.easeInOut,
|
||||
);
|
||||
});
|
||||
}
|
||||
},
|
||||
label: submitDisabled && step == totalSteps
|
||||
? const CircularProgressIndicator()
|
||||
: widget.isCanceled == true && step != totalSteps
|
||||
? Icon(
|
||||
Icons.arrow_forward_ios_outlined,
|
||||
color: appTheme.blueA400,
|
||||
)
|
||||
: Text(
|
||||
step == totalSteps ? 'Отправить' : 'Продолжить',
|
||||
style: theme.textTheme.titleSmall!.copyWith(
|
||||
color: Colors.white,
|
||||
},
|
||||
label: submitDisabled && step == totalSteps
|
||||
? const CircularProgressIndicator()
|
||||
: widget.isCanceled == true && step != totalSteps
|
||||
? Icon(
|
||||
Icons.arrow_forward_ios_outlined,
|
||||
color: appTheme.blueA400,
|
||||
)
|
||||
: Text(
|
||||
step == totalSteps ? 'Отправить' : 'Продолжить',
|
||||
style: theme.textTheme.titleSmall!.copyWith(
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
appBar: AppBar(
|
||||
automaticallyImplyLeading: false,
|
||||
leading: Builder(
|
||||
builder: (context) => IconButton(
|
||||
icon: const Icon(Icons.close_rounded),
|
||||
onPressed: () => GoRouter.of(context).pop(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
appBar: AppBar(
|
||||
automaticallyImplyLeading: false,
|
||||
leading: Builder(
|
||||
builder: (context) => IconButton(
|
||||
icon: const Icon(Icons.close_rounded),
|
||||
onPressed: () => GoRouter.of(context).pop(),
|
||||
centerTitle: true,
|
||||
title: SvgPicture.asset(
|
||||
ImageConstant.imgLogoEatvio,
|
||||
height: 48,
|
||||
fit: BoxFit.contain,
|
||||
),
|
||||
),
|
||||
centerTitle: true,
|
||||
title: SvgPicture.asset(
|
||||
ImageConstant.imgLogoEatvio,
|
||||
height: 48,
|
||||
fit: BoxFit.contain,
|
||||
),
|
||||
actions: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 16),
|
||||
child: Text('шаг $step из $totalSteps'),
|
||||
),
|
||||
],
|
||||
bottom: PreferredSize(
|
||||
preferredSize: Size(MediaQuery.of(context).size.width, 32),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: TweenAnimationBuilder<double>(
|
||||
duration: const Duration(milliseconds: 350),
|
||||
curve: Curves.easeInOut,
|
||||
tween: Tween<double>(
|
||||
begin: 1 / totalSteps,
|
||||
end: progressValue,
|
||||
),
|
||||
builder: (context, value, _) => LinearProgressIndicator(
|
||||
value: value,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
minHeight: 8,
|
||||
backgroundColor: Colors.grey.shade100,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
appTheme.blueA400,
|
||||
actions: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 16),
|
||||
child: Text('шаг $step из $totalSteps'),
|
||||
),
|
||||
],
|
||||
bottom: PreferredSize(
|
||||
preferredSize: Size(MediaQuery.of(context).size.width, 32),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: TweenAnimationBuilder<double>(
|
||||
duration: const Duration(milliseconds: 350),
|
||||
curve: Curves.easeInOut,
|
||||
tween: Tween<double>(
|
||||
begin: 1 / totalSteps,
|
||||
end: progressValue,
|
||||
),
|
||||
builder: (context, value, _) => LinearProgressIndicator(
|
||||
value: value,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
minHeight: 8,
|
||||
backgroundColor: Colors.grey.shade100,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
appTheme.blueA400,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
body: PageView(
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
controller: pageController,
|
||||
onPageChanged: (index) {
|
||||
setState(() {
|
||||
step = index + 1;
|
||||
progressValue = step / totalSteps;
|
||||
});
|
||||
},
|
||||
children: _pages(),
|
||||
),
|
||||
);
|
||||
},
|
||||
body: PageView(
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
controller: pageController,
|
||||
onPageChanged: (index) {
|
||||
setState(() {
|
||||
step = index + 1;
|
||||
progressValue = step / totalSteps;
|
||||
});
|
||||
},
|
||||
children: _pages(),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -18,7 +18,8 @@ class LaravelValidationErrors {
|
||||
}
|
||||
|
||||
const _genericValidationSummary = 'Проверьте введённые данные';
|
||||
const _genericNetworkSummary = 'Не удалось отправить данные. Попробуйте ещё раз';
|
||||
const _genericNetworkSummary =
|
||||
'Не удалось отправить данные. Попробуйте ещё раз';
|
||||
|
||||
/// Parses Laravel `errors` map and maps them to user-facing Russian texts.
|
||||
/// Raw backend messages are never shown in the UI.
|
||||
@@ -85,8 +86,7 @@ String mapConsultantValidationMessage({
|
||||
raw.contains('required') ||
|
||||
raw.contains('обязательн') ||
|
||||
raw.contains('required_if');
|
||||
final isAccepted =
|
||||
raw.contains('accepted') || field.contains('agreement');
|
||||
final isAccepted = raw.contains('accepted') || field.contains('agreement');
|
||||
final isUrl = raw.contains('url') || field.contains('link');
|
||||
final isFormat =
|
||||
raw.contains('format') ||
|
||||
@@ -154,6 +154,9 @@ String mapConsultantValidationMessage({
|
||||
'passport_number' => 'Некорректный номер паспорта',
|
||||
'passport_issue_date' => 'Некорректная дата выдачи',
|
||||
'passport_issuer_code' => 'Некорректный код подразделения',
|
||||
'last_name' => 'Некорректная фамилия',
|
||||
'first_name' => 'Некорректное имя',
|
||||
'middle_name' => 'Некорректное отчество',
|
||||
'birth_date' => 'Некорректная дата рождения',
|
||||
'citizenship' => 'Некорректный код гражданства',
|
||||
'sbp_phone' => 'Некорректный телефон СБП',
|
||||
|
||||
@@ -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(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: MediaQuery.of(context).size.width * 0.8 - 32,
|
||||
child: CustomTextFormField(
|
||||
controller: productNameController,
|
||||
autofocus: true,
|
||||
hintText: 'Введите название продукта/блюда',
|
||||
onChanged: (query) {
|
||||
EasyDebounce.debounce('glossary-search', const Duration(milliseconds: 250), () async {
|
||||
selectedGlossary = null;
|
||||
if (query.isEmpty) {
|
||||
glossariesList = [];
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: SizedBox(
|
||||
child: CustomTextFormField(
|
||||
controller: productNameController,
|
||||
autofocus: true,
|
||||
hintText: 'Введите название продукта/блюда',
|
||||
onChanged: (query) {
|
||||
EasyDebounce.debounce('glossary-search', const Duration(milliseconds: 250), () async {
|
||||
selectedGlossary = null;
|
||||
if (query.isEmpty) {
|
||||
glossariesList = [];
|
||||
selectedGlossary = null;
|
||||
setState(() {});
|
||||
return;
|
||||
}
|
||||
glossariesList = await widget.createDietCubit.getGlossaryListByQuery(query);
|
||||
setState(() {});
|
||||
return;
|
||||
}
|
||||
glossariesList = await widget.createDietCubit.getGlossaryListByQuery(query);
|
||||
setState(() {});
|
||||
});
|
||||
},
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
SizedBox(
|
||||
width: MediaQuery.of(context).size.width * 0.2 - 4,
|
||||
child: CustomTextFormField(
|
||||
controller: quantityController,
|
||||
keyboardType: TextInputType.number,
|
||||
hintText: selectedGlossary == null
|
||||
? '100'
|
||||
: selectedGlossary!.grInPortion == 0
|
||||
? '100'
|
||||
: '1',
|
||||
suffixText: selectedGlossary == null
|
||||
? 'гр'
|
||||
: selectedGlossary?.grInPortion == 0
|
||||
? 'гр'
|
||||
: 'шт',
|
||||
Expanded(
|
||||
flex: 1,
|
||||
child: SizedBox(
|
||||
child: CustomTextFormField(
|
||||
controller: quantityController,
|
||||
keyboardType: TextInputType.number,
|
||||
hintText: selectedGlossary == null
|
||||
? '100'
|
||||
: selectedGlossary!.grInPortion == 0
|
||||
? '100'
|
||||
: '1',
|
||||
suffixText: selectedGlossary == null
|
||||
? 'гр'
|
||||
: selectedGlossary?.grInPortion == 0
|
||||
? 'гр'
|
||||
: 'шт',
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -149,17 +153,19 @@ class _AddMealWidgetState extends State<AddMealWidget> {
|
||||
),
|
||||
),
|
||||
if (widget.portionIndex != null) const SizedBox(width: 8),
|
||||
InkWell(
|
||||
onTap: addPortion,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
child: Ink(
|
||||
width: MediaQuery.of(context).size.width - (widget.portionIndex == null ? 32 : 92),
|
||||
height: 52,
|
||||
decoration: BoxDecoration(borderRadius: BorderRadius.circular(20), color: appTheme.blueA400),
|
||||
child: Center(
|
||||
child: Text(
|
||||
widget.portionIndex == null ? 'Добавить' : 'Сохранить',
|
||||
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: Colors.white),
|
||||
Expanded(
|
||||
child: InkWell(
|
||||
onTap: addPortion,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
child: Ink(
|
||||
width: MediaQuery.of(context).size.width - (widget.portionIndex == null ? 32 : 92),
|
||||
height: 52,
|
||||
decoration: BoxDecoration(borderRadius: BorderRadius.circular(20), color: appTheme.blueA400),
|
||||
child: Center(
|
||||
child: Text(
|
||||
widget.portionIndex == null ? 'Добавить' : 'Сохранить',
|
||||
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: Colors.white),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -53,7 +53,7 @@ class _CreatePlanScreenContentState extends State<CreatePlanScreenContent> {
|
||||
if (state.planCreated) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!context.mounted) return;
|
||||
context.pop();
|
||||
context.pop(true);
|
||||
showBottomSheetWithBlur(
|
||||
userRootNavigator: true,
|
||||
context: context,
|
||||
|
||||
@@ -70,42 +70,46 @@ class _AddProductModalState extends State<AddProductModal> {
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: MediaQuery.of(context).size.width * 0.76 - 32,
|
||||
child: CustomTextFormField(
|
||||
controller: productNameController,
|
||||
autofocus: true,
|
||||
hintText: 'Введите название продукта/блюда',
|
||||
onChanged: (query) {
|
||||
EasyDebounce.debounce('glossary-search', const Duration(milliseconds: 250), () async {
|
||||
selectedGlossary = null;
|
||||
if (query.isEmpty) {
|
||||
glossariesList = [];
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: SizedBox(
|
||||
child: CustomTextFormField(
|
||||
controller: productNameController,
|
||||
autofocus: true,
|
||||
hintText: 'Введите название продукта/блюда',
|
||||
onChanged: (query) {
|
||||
EasyDebounce.debounce('glossary-search', const Duration(milliseconds: 250), () async {
|
||||
selectedGlossary = null;
|
||||
if (query.isEmpty) {
|
||||
glossariesList = [];
|
||||
setState(() {});
|
||||
return;
|
||||
}
|
||||
glossariesList = await widget.diaryCubit.getGlossaryListByQuery(query);
|
||||
setState(() {});
|
||||
return;
|
||||
}
|
||||
glossariesList = await widget.diaryCubit.getGlossaryListByQuery(query);
|
||||
setState(() {});
|
||||
});
|
||||
},
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
SizedBox(
|
||||
width: MediaQuery.of(context).size.width * 0.24 - 4,
|
||||
child: CustomTextFormField(
|
||||
controller: quantityController,
|
||||
keyboardType: TextInputType.number,
|
||||
hintText: selectedGlossary == null
|
||||
? '100'
|
||||
: selectedGlossary!.grInPortion == 0
|
||||
? '100'
|
||||
: '1',
|
||||
suffixText: selectedGlossary == null
|
||||
? 'гр'
|
||||
: selectedGlossary?.grInPortion == 0
|
||||
? 'гр'
|
||||
: 'шт',
|
||||
Expanded(
|
||||
flex: 1,
|
||||
child: SizedBox(
|
||||
child: CustomTextFormField(
|
||||
controller: quantityController,
|
||||
keyboardType: TextInputType.number,
|
||||
hintText: selectedGlossary == null
|
||||
? '100'
|
||||
: selectedGlossary!.grInPortion == 0
|
||||
? '100'
|
||||
: '1',
|
||||
suffixText: selectedGlossary == null
|
||||
? 'гр'
|
||||
: selectedGlossary?.grInPortion == 0
|
||||
? 'гр'
|
||||
: 'шт',
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -168,66 +172,68 @@ class _AddProductModalState extends State<AddProductModal> {
|
||||
),
|
||||
),
|
||||
if (widget.portion != null) const SizedBox(width: 8),
|
||||
InkWell(
|
||||
onTap: () {
|
||||
// Автоматически выбираем первый элемент, если текст точно совпадает
|
||||
if (selectedGlossary == null &&
|
||||
glossariesList.isNotEmpty &&
|
||||
productNameController.text.trim().toLowerCase() == glossariesList[0].name.toLowerCase()) {
|
||||
selectedGlossary = glossariesList[0];
|
||||
}
|
||||
Expanded(
|
||||
child: InkWell(
|
||||
onTap: () {
|
||||
// Автоматически выбираем первый элемент, если текст точно совпадает
|
||||
if (selectedGlossary == null &&
|
||||
glossariesList.isNotEmpty &&
|
||||
productNameController.text.trim().toLowerCase() == glossariesList[0].name.toLowerCase()) {
|
||||
selectedGlossary = glossariesList[0];
|
||||
}
|
||||
|
||||
if (widget.portion == null) {
|
||||
widget.diaryCubit.addProductToMeal(
|
||||
diaryId: widget.state.diary.id,
|
||||
mealId: widget.state.selectedMeal,
|
||||
productName: productNameController.text,
|
||||
portion: selectedGlossary == null
|
||||
? 'gr'
|
||||
: selectedGlossary!.grInPortion == 0
|
||||
? 'gr'
|
||||
: 'pcs',
|
||||
quantity: int.parse(quantityController.text),
|
||||
productNutritionalValueId: selectedGlossary?.id,
|
||||
);
|
||||
} else {
|
||||
widget.diaryCubit.updateProductInMeal(
|
||||
diaryId: widget.state.diary.id,
|
||||
mealId: widget.state.selectedMeal,
|
||||
portionId: widget.portion!.id,
|
||||
productName: productNameController.text,
|
||||
portion: selectedGlossary == null
|
||||
? 'gr'
|
||||
: selectedGlossary!.grInPortion == 0
|
||||
? 'gr'
|
||||
: 'pcs',
|
||||
quantity: int.parse(quantityController.text),
|
||||
productNutritionalValueId: selectedGlossary?.id,
|
||||
portionState: widget.portion!.portionState,
|
||||
);
|
||||
}
|
||||
Navigator.of(context).pop();
|
||||
if (selectedGlossary == null && _previousProductName != productNameController.text) {
|
||||
showModalBottomSheet(
|
||||
isScrollControlled: true,
|
||||
useRootNavigator: true,
|
||||
context: context,
|
||||
builder: (context) => Padding(
|
||||
padding: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom),
|
||||
child: GlossaryModerationRequestModalWidget(productName: productNameController.text, diaryCubit: widget.diaryCubit),
|
||||
if (widget.portion == null) {
|
||||
widget.diaryCubit.addProductToMeal(
|
||||
diaryId: widget.state.diary.id,
|
||||
mealId: widget.state.selectedMeal,
|
||||
productName: productNameController.text,
|
||||
portion: selectedGlossary == null
|
||||
? 'gr'
|
||||
: selectedGlossary!.grInPortion == 0
|
||||
? 'gr'
|
||||
: 'pcs',
|
||||
quantity: int.parse(quantityController.text),
|
||||
productNutritionalValueId: selectedGlossary?.id,
|
||||
);
|
||||
} else {
|
||||
widget.diaryCubit.updateProductInMeal(
|
||||
diaryId: widget.state.diary.id,
|
||||
mealId: widget.state.selectedMeal,
|
||||
portionId: widget.portion!.id,
|
||||
productName: productNameController.text,
|
||||
portion: selectedGlossary == null
|
||||
? 'gr'
|
||||
: selectedGlossary!.grInPortion == 0
|
||||
? 'gr'
|
||||
: 'pcs',
|
||||
quantity: int.parse(quantityController.text),
|
||||
productNutritionalValueId: selectedGlossary?.id,
|
||||
portionState: widget.portion!.portionState,
|
||||
);
|
||||
}
|
||||
Navigator.of(context).pop();
|
||||
if (selectedGlossary == null && _previousProductName != productNameController.text) {
|
||||
showModalBottomSheet(
|
||||
isScrollControlled: true,
|
||||
useRootNavigator: true,
|
||||
context: context,
|
||||
builder: (context) => Padding(
|
||||
padding: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom),
|
||||
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),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -16,6 +16,9 @@ class CalendarWidget extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _CalendarWidgetState extends State<CalendarWidget> {
|
||||
static const _kDaysOfWeekHeight = 16.0;
|
||||
static const _kRowHeight = 52.0;
|
||||
|
||||
CalendarFormat _calendarFormat = CalendarFormat.week;
|
||||
DateTime _focusedDay = DateTime.now();
|
||||
DateTime _selectedDay = DateTime.now();
|
||||
@@ -40,6 +43,8 @@ class _CalendarWidgetState extends State<CalendarWidget> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final s = S.of(context);
|
||||
final textScaler = MediaQuery.textScalerOf(context);
|
||||
final daysOfWeekHeight = _scaledDaysOfWeekHeight(context);
|
||||
return BlocConsumer<DiaryCubit, DiaryState>(
|
||||
listener: (BuildContext context, DiaryState state) {
|
||||
if (state is DiaryStateLoaded) {
|
||||
@@ -61,6 +66,8 @@ class _CalendarWidgetState extends State<CalendarWidget> {
|
||||
locale: 'ru_RU',
|
||||
startingDayOfWeek: StartingDayOfWeek.monday,
|
||||
calendarFormat: _calendarFormat,
|
||||
daysOfWeekHeight: daysOfWeekHeight,
|
||||
rowHeight: textScaler.scale(_kRowHeight),
|
||||
headerStyle: const HeaderStyle(titleCentered: true, formatButtonVisible: false, leftChevronVisible: false, rightChevronVisible: false),
|
||||
onDaySelected: (selectedDay, focusedDay) async {
|
||||
if (!isSameDay(_selectedDay, selectedDay)) {
|
||||
@@ -116,6 +123,18 @@ class _CalendarWidgetState extends State<CalendarWidget> {
|
||||
);
|
||||
}
|
||||
|
||||
double _scaledDaysOfWeekHeight(BuildContext context) {
|
||||
final painter = TextPainter(
|
||||
text: TextSpan(text: 'Щy', style: DefaultTextStyle.of(context).style),
|
||||
textDirection: Directionality.of(context),
|
||||
textScaler: MediaQuery.textScalerOf(context),
|
||||
maxLines: 1,
|
||||
)..layout();
|
||||
final height = painter.height;
|
||||
painter.dispose();
|
||||
return height.clamp(_kDaysOfWeekHeight, double.infinity);
|
||||
}
|
||||
|
||||
void _nextCalendarFormat() {
|
||||
setState(() {
|
||||
_calendarFormat = CalendarFormat.values[(_calendarFormat.index - 1) % CalendarFormat.values.length];
|
||||
|
||||
@@ -92,6 +92,7 @@ class DietsCubit extends Cubit<DietsState> {
|
||||
}) async {
|
||||
try {
|
||||
_query = query;
|
||||
currentPage = 1;
|
||||
final type = _types[state.filterState.typeIndex];
|
||||
final duration = _durationValues[state.filterState.durationIndex];
|
||||
final sorting = _sortingValues[state.filterState.sortingIndex];
|
||||
|
||||
@@ -149,6 +149,7 @@ class PlansCubit extends Cubit<PlansState> {
|
||||
}) async {
|
||||
try {
|
||||
_query = query;
|
||||
currentPage = 1;
|
||||
final type = _types[state.filterState.typeIndex];
|
||||
final duration = _durationValues[state.filterState.durationIndex];
|
||||
final minPrice = state.filterState.priceFilter.$1;
|
||||
|
||||
@@ -46,6 +46,7 @@ class RecipesCubit extends Cubit<RecipesState> {
|
||||
}) async {
|
||||
try {
|
||||
_query = query;
|
||||
currentPage = 1;
|
||||
final recipesList = await feedRepository.getRecipes(
|
||||
tags: _selectedTags.toList(),
|
||||
query: query,
|
||||
|
||||
@@ -123,6 +123,19 @@ class _FeedScreenContentState extends State<FeedScreenContent> {
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _openCreateAndReloadFeed({
|
||||
required Future<Object?> Function() openCreate,
|
||||
required Future<void> Function() reload,
|
||||
}) async {
|
||||
final result = await openCreate();
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
if (shouldReloadFeedAfterCreate(result)) {
|
||||
await reload();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
FlutterNativeSplash.remove();
|
||||
@@ -254,26 +267,43 @@ class _FeedScreenContentState extends State<FeedScreenContent> {
|
||||
const SizedBox(width: 12),
|
||||
switch (_selectedChipIndex) {
|
||||
1 when !hidePlans => AddItemButton(
|
||||
onTap: state.profile?.roles.contains('consultant') ?? false ? () => GoRouter.of(context).pushNamed(CreatePlanScreen.name) : null,
|
||||
onTap: state.profile?.roles.contains('consultant') ?? false
|
||||
? () => _openCreateAndReloadFeed(
|
||||
openCreate: () => GoRouter.of(context).pushNamed(CreatePlanScreen.name),
|
||||
reload: () => context.read<PlansCubit>().loadPlans(),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
2 when hidePlans => AddItemButton(
|
||||
onTap: state.profile?.roles.contains('consultant') ?? false ? () => GoRouter.of(context).pushNamed(RecipeCreateScreen.name) : null,
|
||||
onTap: state.profile?.roles.contains('consultant') ?? false
|
||||
? () => _openCreateAndReloadFeed(
|
||||
openCreate: () => GoRouter.of(context).pushNamed(RecipeCreateScreen.name),
|
||||
reload: () => context.read<RecipesCubit>().loadRecipesList(),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
3 when !hidePlans => AddItemButton(
|
||||
onTap: state.profile?.roles.contains('consultant') ?? false ? () => GoRouter.of(context).pushNamed(RecipeCreateScreen.name) : null,
|
||||
onTap: state.profile?.roles.contains('consultant') ?? false
|
||||
? () => _openCreateAndReloadFeed(
|
||||
openCreate: () => GoRouter.of(context).pushNamed(RecipeCreateScreen.name),
|
||||
reload: () => context.read<RecipesCubit>().loadRecipesList(),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
3 when hidePlans => AddItemButton(
|
||||
onTap: state.profile?.roles.contains('consultant') ?? false
|
||||
? () async {
|
||||
await GoRouter.of(context).pushNamed(CreateDietScreen.name, extra: {'isEditing': false});
|
||||
}
|
||||
? () => _openCreateAndReloadFeed(
|
||||
openCreate: () => GoRouter.of(context).pushNamed(CreateDietScreen.name, extra: {'isEditing': false}),
|
||||
reload: () => context.read<DietsCubit>().loadDiets(),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
4 when !hidePlans => AddItemButton(
|
||||
onTap: state.profile?.roles.contains('consultant') ?? false
|
||||
? () async {
|
||||
await GoRouter.of(context).pushNamed(CreateDietScreen.name);
|
||||
}
|
||||
? () => _openCreateAndReloadFeed(
|
||||
openCreate: () => GoRouter.of(context).pushNamed(CreateDietScreen.name),
|
||||
reload: () => context.read<DietsCubit>().loadDiets(),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
_ => const AddItemButton(key: AppKeys.feedScreenBlogPostCreationButton),
|
||||
@@ -301,6 +331,9 @@ class _FeedScreenContentState extends State<FeedScreenContent> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Create screens pop `true` (diet/plan) or `'active'` (recipe) after a successful publish.
|
||||
bool shouldReloadFeedAfterCreate(Object? result) => result == true || result == 'active';
|
||||
|
||||
enum ViewableContentType {
|
||||
plans,
|
||||
posts,
|
||||
|
||||
@@ -221,49 +221,51 @@ class _GeneralInfoScreenState extends State<GeneralInfoScreen> {
|
||||
}
|
||||
|
||||
Widget _saveButton() {
|
||||
return isLoading
|
||||
? const Center(child: Loader())
|
||||
: InkWell(
|
||||
onTap: () async {
|
||||
final hasError = _checkFields();
|
||||
if (hasError) {
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
isLoading = true;
|
||||
});
|
||||
await widget.settingsEntity.settingsCubit.updateProfileDetails(
|
||||
profileDetailsModel: (widget.settingsEntity.settingsCubit.state as SettingsStateLoaded).profileDetails.copyWith(
|
||||
firstName: firstNameController.text,
|
||||
lastName: lastNameController.text,
|
||||
birthdate: selectedDate.toString(),
|
||||
gender: gender.value,
|
||||
country: countryController.text,
|
||||
city: cityController.text,
|
||||
lang: languageController.text,
|
||||
),
|
||||
);
|
||||
await widget.settingsEntity.settingsCubit.updateProfileSettings(
|
||||
profileSettingsModel: (widget.settingsEntity.settingsCubit.state as SettingsStateLoaded).profileSettings.copyWith(
|
||||
isPublicProfile: isPublicProfile.value,
|
||||
),
|
||||
);
|
||||
setState(() {
|
||||
isLoading = false;
|
||||
});
|
||||
},
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
child: Ink(
|
||||
height: 52,
|
||||
width: MediaQuery.of(context).size.width - 32,
|
||||
decoration: BoxDecoration(borderRadius: BorderRadius.circular(20), color: appTheme.blueA400),
|
||||
child: const Center(
|
||||
child: Text(
|
||||
'Сохранить',
|
||||
style: TextStyle(color: Colors.white, height: 1, fontWeight: FontWeight.w600, fontSize: 18),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
if (isLoading) {
|
||||
return const SizedBox(height: 52, child: Loader());
|
||||
}
|
||||
return InkWell(
|
||||
onTap: () async {
|
||||
final hasError = _checkFields();
|
||||
if (hasError) {
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
isLoading = true;
|
||||
});
|
||||
await widget.settingsEntity.settingsCubit.updateProfileDetails(
|
||||
profileDetailsModel: (widget.settingsEntity.settingsCubit.state as SettingsStateLoaded).profileDetails.copyWith(
|
||||
firstName: firstNameController.text,
|
||||
lastName: lastNameController.text,
|
||||
birthdate: selectedDate.toString(),
|
||||
gender: gender.value,
|
||||
country: countryController.text,
|
||||
city: cityController.text,
|
||||
lang: languageController.text,
|
||||
),
|
||||
);
|
||||
await widget.settingsEntity.settingsCubit.updateProfileSettings(
|
||||
profileSettingsModel: (widget.settingsEntity.settingsCubit.state as SettingsStateLoaded).profileSettings.copyWith(
|
||||
isPublicProfile: isPublicProfile.value,
|
||||
),
|
||||
);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
isLoading = false;
|
||||
});
|
||||
},
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
child: Ink(
|
||||
height: 52,
|
||||
width: MediaQuery.of(context).size.width - 32,
|
||||
decoration: BoxDecoration(borderRadius: BorderRadius.circular(20), color: appTheme.blueA400),
|
||||
child: const Center(
|
||||
child: Text(
|
||||
'Сохранить',
|
||||
style: TextStyle(color: Colors.white, height: 1, fontWeight: FontWeight.w600, fontSize: 18),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,17 +92,9 @@ class VideoCallCallKitCoordinator {
|
||||
onResumed: _onAndroidAppResumed,
|
||||
);
|
||||
WidgetsBinding.instance.addObserver(_androidResumeObserver!);
|
||||
_androidCallPushChannel.setMethodCallHandler(_onAndroidCallPushMethodCall);
|
||||
await FlutterCallkitIncoming.requestNotificationPermission(<
|
||||
String,
|
||||
dynamic
|
||||
>{
|
||||
'title': 'Уведомления',
|
||||
'rationaleMessagePermission':
|
||||
'Разрешите уведомления, чтобы получать входящие видеозвонки.',
|
||||
'postNotificationMessageRequired':
|
||||
'Разрешите уведомления в настройках, чтобы получать входящие видеозвонки.',
|
||||
});
|
||||
_androidCallPushChannel.setMethodCallHandler(
|
||||
_onAndroidCallPushMethodCall,
|
||||
);
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
unawaited(_pollNativePendingAcceptAfterResume());
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
name: eatvio_app
|
||||
description: A new Flutter project.
|
||||
version: 1.3.38+127
|
||||
version: 1.3.42+131
|
||||
publish_to: 'none'
|
||||
|
||||
environment:
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:eatvio_app/core/notifications/notification_permission_requester.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
group('NotificationPermissionRequester', () {
|
||||
test('вызывает requestPermission один раз', () async {
|
||||
final requester = NotificationPermissionRequester();
|
||||
var calls = 0;
|
||||
|
||||
await requester.requestOnce(() async {
|
||||
calls++;
|
||||
});
|
||||
|
||||
expect(calls, 1);
|
||||
});
|
||||
|
||||
test('параллельные вызовы ждут один и тот же запрос', () async {
|
||||
final requester = NotificationPermissionRequester();
|
||||
var calls = 0;
|
||||
|
||||
Future<void> request() {
|
||||
return requester.requestOnce(() async {
|
||||
calls++;
|
||||
await Future<void>.delayed(const Duration(milliseconds: 50));
|
||||
});
|
||||
}
|
||||
|
||||
await Future.wait<void>([request(), request(), request()]);
|
||||
|
||||
expect(calls, 1);
|
||||
});
|
||||
|
||||
test('после завершения можно запросить снова', () async {
|
||||
final requester = NotificationPermissionRequester();
|
||||
var calls = 0;
|
||||
|
||||
Future<void> request() {
|
||||
return requester.requestOnce(() async {
|
||||
calls++;
|
||||
});
|
||||
}
|
||||
|
||||
await request();
|
||||
await request();
|
||||
|
||||
expect(calls, 2);
|
||||
});
|
||||
});
|
||||
|
||||
group('единая точка запроса разрешения на уведомления', () {
|
||||
test('старт приложения и CallKit не показывают отдельные системные окна', () {
|
||||
final initializer = File('lib/core/app_initializer.dart').readAsStringSync();
|
||||
final callkit = File('lib/features/video_calls/services/video_call_callkit_coordinator.dart').readAsStringSync();
|
||||
final firebaseApi = File('lib/core/firebase_api.dart').readAsStringSync();
|
||||
|
||||
expect(initializer.contains('requestPermission'), isFalse);
|
||||
expect(callkit.contains('requestNotificationPermission'), isFalse);
|
||||
expect(firebaseApi.contains('_requestNotificationPermission'), isTrue);
|
||||
expect(firebaseApi.contains('_firebaseMessaging.requestPermission()'), isTrue);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -200,7 +200,7 @@ void main() {
|
||||
|
||||
test('markMessagesAsRead - отмечает сообщения как прочитанные', () async {
|
||||
const chatId = 'chat-id';
|
||||
final messagesIds = [(id: 1, index: 0)];
|
||||
final messagesIds = [1];
|
||||
|
||||
// Сначала инициализируем чат
|
||||
when(mockChatRepository.getChatRoom(any)).thenAnswer((_) async => testChatRoom);
|
||||
@@ -1443,5 +1443,218 @@ void main() {
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:eatvio_app/core/app_config.dart';
|
||||
import 'package:eatvio_app/core/app_initializer.dart';
|
||||
import 'package:eatvio_app/core/enums/complained_content.dart';
|
||||
import 'package:eatvio_app/core/locator_service.dart';
|
||||
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
||||
import 'package:eatvio_app/core/widgets/loader.dart';
|
||||
import 'package:eatvio_app/features/complaining/presentation/complaining_bottom_sheet.dart';
|
||||
import 'package:eatvio_app/features/complaining/presentation/cubit/complaining_cubit.dart';
|
||||
import 'package:eatvio_app/features/complaining/repositories/complaining_repository.dart';
|
||||
@@ -12,6 +15,7 @@ import 'package:flutter_dotenv/flutter_dotenv.dart';
|
||||
import 'package:flutter_localizations/flutter_localizations.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:mockito/annotations.dart';
|
||||
import 'package:mockito/mockito.dart';
|
||||
|
||||
import 'complaining_bottom_sheet_smoke_test.mocks.dart';
|
||||
|
||||
@@ -59,7 +63,40 @@ void main() {
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(ComplainingBottomSheet), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('replaces send button with Loader while request is in progress', (tester) async {
|
||||
final completer = Completer<void>();
|
||||
when(
|
||||
repository.sendPlanComplain(
|
||||
slug: anyNamed('slug'),
|
||||
category: anyNamed('category'),
|
||||
body: anyNamed('body'),
|
||||
),
|
||||
).thenAnswer((_) => completer.future);
|
||||
|
||||
await tester.pumpWidget(
|
||||
wrap(
|
||||
const ComplainingBottomSheet(
|
||||
complainedContentType: ComplainedContent.plan,
|
||||
slug: 'test-slug',
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.text('Спам'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Отправить жалобу'), findsOneWidget);
|
||||
await tester.tap(find.text('Отправить жалобу'));
|
||||
await tester.pump();
|
||||
|
||||
expect(find.byType(Loader), findsOneWidget);
|
||||
expect(find.text('Отправить жалобу'), findsNothing);
|
||||
|
||||
completer.complete();
|
||||
await tester.pump();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:eatvio_app/core/enums/complained_content.dart';
|
||||
import 'package:eatvio_app/features/complaining/models/complain_model.dart';
|
||||
import 'package:eatvio_app/features/complaining/presentation/complaining_bottom_sheet.dart';
|
||||
@@ -97,6 +99,7 @@ void main() {
|
||||
await cubit.complain();
|
||||
|
||||
expect(cubit.state.wasSentSuccessfully, true);
|
||||
expect(cubit.state.isLoading, false);
|
||||
verify(() => mockRepository.sendDietComplain(
|
||||
id: id,
|
||||
category: any(named: 'category'),
|
||||
@@ -104,6 +107,30 @@ void main() {
|
||||
)).called(1);
|
||||
});
|
||||
|
||||
test('complain - ставит isLoading на время запроса', () async {
|
||||
const id = 1;
|
||||
final completer = Completer<void>();
|
||||
|
||||
when(() => mockRepository.sendDietComplain(
|
||||
id: id,
|
||||
category: any(named: 'category'),
|
||||
body: any(named: 'body'),
|
||||
)).thenAnswer((_) => completer.future);
|
||||
|
||||
cubit.setContentTypeAndIdentifier(contentType: ComplainedContent.diet, id: id);
|
||||
cubit.setCategory(ComplainType.spam);
|
||||
|
||||
final future = cubit.complain();
|
||||
expect(cubit.state.isLoading, true);
|
||||
expect(cubit.state.wasSentSuccessfully, isNull);
|
||||
|
||||
completer.complete();
|
||||
await future;
|
||||
|
||||
expect(cubit.state.isLoading, false);
|
||||
expect(cubit.state.wasSentSuccessfully, true);
|
||||
});
|
||||
|
||||
test('complain - возвращает false при ошибке', () async {
|
||||
const id = 1;
|
||||
|
||||
@@ -119,6 +146,7 @@ void main() {
|
||||
await cubit.complain();
|
||||
|
||||
expect(cubit.state.wasSentSuccessfully, false);
|
||||
expect(cubit.state.isLoading, false);
|
||||
});
|
||||
|
||||
test('complain - отправляет жалобу на plan', () async {
|
||||
|
||||
@@ -158,6 +158,10 @@ void main() {
|
||||
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,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -463,7 +463,30 @@ void main() {
|
||||
expect(cubit.state.isScoringPolling, isFalse);
|
||||
});
|
||||
|
||||
test('registerConsultant blocks submit without passport data', () async {
|
||||
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'),
|
||||
@@ -473,7 +496,10 @@ void main() {
|
||||
|
||||
await cubit.registerConsultant();
|
||||
|
||||
expect(cubit.state.error, 'Заполните паспортные данные');
|
||||
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'),
|
||||
@@ -482,6 +508,19 @@ void main() {
|
||||
);
|
||||
});
|
||||
|
||||
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(
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'package:eatvio_app/features/consultant/become_consultant/presentation/bl
|
||||
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/passport_data_screen.dart';
|
||||
import 'package:eatvio_app/features/consultant/become_consultant/repositories/consultant_repository.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';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
@@ -60,5 +61,19 @@ void main() {
|
||||
expect(find.text('Банк получатель (поиск)'), findsOneWidget);
|
||||
expect(find.text('Я являюсь налоговым резидентом США'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('shows ValidationErrorWidget for invalid field', (
|
||||
tester,
|
||||
) async {
|
||||
cubit.emit(
|
||||
cubit.state.copyWith(fieldErrors: const {'inn': 'Некорректный ИНН'}),
|
||||
);
|
||||
|
||||
await tester.pumpWidget(wrap(const PassportDataScreen()));
|
||||
await tester.pump();
|
||||
|
||||
expect(find.byType(ValidationErrorWidget), findsOneWidget);
|
||||
expect(find.text('Некорректный ИНН'), findsOneWidget);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -49,7 +49,26 @@ void main() {
|
||||
);
|
||||
|
||||
expect(parsed.fieldErrors['last_name'], 'Укажите фамилию');
|
||||
expect(parsed.fieldErrors['agreement_accepted'], 'Примите условия оферты');
|
||||
expect(
|
||||
parsed.fieldErrors['agreement_accepted'],
|
||||
'Примите условия оферты',
|
||||
);
|
||||
});
|
||||
|
||||
test('maps name format errors', () {
|
||||
final parsed = parseLaravelValidationErrors(
|
||||
dio422({
|
||||
'errors': {
|
||||
'last_name': ['The last name format is invalid.'],
|
||||
'first_name': ['The first name format is invalid.'],
|
||||
'middle_name': ['The middle name format is invalid.'],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(parsed.fieldErrors['last_name'], 'Некорректная фамилия');
|
||||
expect(parsed.fieldErrors['first_name'], 'Некорректное имя');
|
||||
expect(parsed.fieldErrors['middle_name'], 'Некорректное отчество');
|
||||
});
|
||||
|
||||
test('falls back to generic message for non-422 DioException', () {
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import 'package:eatvio_app/features/consultant/become_consultant/models/consultant_profile_model.dart';
|
||||
import 'package:eatvio_app/features/consultant/become_consultant/utils/passport_data_validator.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
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',
|
||||
);
|
||||
|
||||
group('PassportDataValidator', () {
|
||||
test('returns empty map for a valid individual person profile', () {
|
||||
expect(PassportDataValidator.validate(valid), isEmpty);
|
||||
});
|
||||
|
||||
test('rejects names that do not match Alfa Bank pattern', () {
|
||||
final errors = PassportDataValidator.validate(
|
||||
valid.copyWith(
|
||||
lastName: 'Regress14LastName',
|
||||
firstName: 'Regress14Name',
|
||||
middleName: 'Regress14middleName',
|
||||
),
|
||||
);
|
||||
|
||||
expect(errors['last_name'], 'Некорректная фамилия');
|
||||
expect(errors['first_name'], 'Некорректное имя');
|
||||
expect(errors['middle_name'], 'Некорректное отчество');
|
||||
});
|
||||
|
||||
test('allows hyphenated names and empty middle name', () {
|
||||
final errors = PassportDataValidator.validate(
|
||||
valid.copyWith(
|
||||
lastName: 'Салтыков-Щедрин',
|
||||
firstName: 'Mary Jane',
|
||||
middleName: '',
|
||||
),
|
||||
);
|
||||
|
||||
expect(errors.containsKey('last_name'), isFalse);
|
||||
expect(errors.containsKey('first_name'), isFalse);
|
||||
expect(errors.containsKey('middle_name'), isFalse);
|
||||
});
|
||||
|
||||
test('requires empty text fields', () {
|
||||
final errors = PassportDataValidator.validate(
|
||||
valid.copyWith(lastName: '', firstName: null),
|
||||
);
|
||||
|
||||
expect(errors['last_name'], 'Укажите фамилию');
|
||||
expect(errors['first_name'], 'Укажите имя');
|
||||
});
|
||||
|
||||
test('rejects short INN and malformed SNILS', () {
|
||||
final errors = PassportDataValidator.validate(
|
||||
valid.copyWith(inn: '123', snils: '12345678900'),
|
||||
);
|
||||
|
||||
expect(errors['inn'], 'Некорректный ИНН');
|
||||
expect(errors['snils'], 'Некорректный СНИЛС');
|
||||
});
|
||||
|
||||
test('rejects empty INN and SNILS', () {
|
||||
final errors = PassportDataValidator.validate(
|
||||
valid.copyWith(inn: '', snils: ''),
|
||||
);
|
||||
|
||||
expect(errors['inn'], 'Укажите ИНН');
|
||||
expect(errors['snils'], 'Укажите СНИЛС');
|
||||
});
|
||||
|
||||
test('rejects non-calendar dates', () {
|
||||
final errors = PassportDataValidator.validate(
|
||||
valid.copyWith(
|
||||
birthDate: '2020-02-31',
|
||||
passportIssueDate: '1999-13-32',
|
||||
),
|
||||
);
|
||||
|
||||
expect(errors['birth_date'], 'Некорректная дата рождения');
|
||||
expect(errors['passport_issue_date'], 'Некорректная дата выдачи');
|
||||
});
|
||||
|
||||
test('rejects dates in the future', () {
|
||||
final errors = PassportDataValidator.validate(
|
||||
valid.copyWith(
|
||||
birthDate: '2030-01-01',
|
||||
passportIssueDate: '2030-01-01',
|
||||
),
|
||||
now: DateTime(2026, 8, 13),
|
||||
);
|
||||
|
||||
expect(errors['birth_date'], PassportDataValidator.futureDateMessage);
|
||||
expect(
|
||||
errors['passport_issue_date'],
|
||||
PassportDataValidator.futureDateMessage,
|
||||
);
|
||||
});
|
||||
|
||||
test('accepts today as a valid date', () {
|
||||
final errors = PassportDataValidator.validate(
|
||||
valid.copyWith(passportIssueDate: '2026-08-13'),
|
||||
now: DateTime(2026, 8, 13, 18),
|
||||
);
|
||||
|
||||
expect(errors.containsKey('passport_issue_date'), isFalse);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -4,9 +4,12 @@ import 'package:eatvio_app/features/diary/presentation/bloc/diary_state.dart';
|
||||
import 'package:eatvio_app/features/diary/presentation/widgets/calendar_widget.dart';
|
||||
import 'package:eatvio_app/generated/l10n.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_localizations/flutter_localizations.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:intl/date_symbol_data_local.dart';
|
||||
import 'package:table_calendar/table_calendar.dart';
|
||||
|
||||
class FakeDiaryCubit extends Cubit<DiaryState> implements DiaryCubit {
|
||||
FakeDiaryCubit(super.initialState);
|
||||
@@ -16,9 +19,20 @@ class FakeDiaryCubit extends Cubit<DiaryState> implements DiaryCubit {
|
||||
|
||||
void main() {
|
||||
group('CalendarWidget smoke', () {
|
||||
Widget wrap(Widget child, DiaryCubit cubit) {
|
||||
setUpAll(() async {
|
||||
await initializeDateFormatting('ru', null);
|
||||
await initializeDateFormatting('ru_RU', null);
|
||||
});
|
||||
|
||||
Widget wrap(Widget child, DiaryCubit cubit, {double textScale = 1}) {
|
||||
return MaterialApp(
|
||||
theme: ThemeHelper().themeData(),
|
||||
builder: (context, appChild) {
|
||||
return MediaQuery(
|
||||
data: MediaQuery.of(context).copyWith(textScaler: TextScaler.linear(textScale)),
|
||||
child: appChild!,
|
||||
);
|
||||
},
|
||||
localizationsDelegates: const [
|
||||
S.delegate,
|
||||
GlobalMaterialLocalizations.delegate,
|
||||
@@ -37,9 +51,26 @@ void main() {
|
||||
final cubit = FakeDiaryCubit(const DiaryState.loading());
|
||||
await tester.pumpWidget(wrap(const CalendarWidget(), cubit));
|
||||
await tester.pump();
|
||||
// Just make sure it builds.
|
||||
expect(find.byType(CalendarWidget), findsOneWidget);
|
||||
await cubit.close();
|
||||
});
|
||||
|
||||
testWidgets('scales weekday row when system font is large', (tester) async {
|
||||
const textScale = 2.0;
|
||||
final cubit = FakeDiaryCubit(const DiaryState.loading());
|
||||
await tester.pumpWidget(wrap(const CalendarWidget(), cubit, textScale: textScale));
|
||||
await tester.pump();
|
||||
|
||||
final calendar = tester.widget<TableCalendar<dynamic>>(find.byType(TableCalendar<dynamic>));
|
||||
expect(calendar.daysOfWeekHeight, greaterThan(16 * textScale * 0.9));
|
||||
expect(calendar.rowHeight, 52 * textScale);
|
||||
|
||||
expect(find.text('пн'), findsWidgets);
|
||||
final weekday = tester.renderObject<RenderParagraph>(find.text('пн').first);
|
||||
expect(weekday.size.height, lessThanOrEqualTo(calendar.daysOfWeekHeight + 0.5));
|
||||
expect(tester.takeException(), isNull);
|
||||
|
||||
await cubit.close();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -156,6 +156,44 @@ void main() {
|
||||
expect(dietsCubit!.state.errorMessage, isNull);
|
||||
});
|
||||
|
||||
test('loadDiets - сбрасывает currentPage при повторной загрузке', () async {
|
||||
dietsCubit = DietsCubit(
|
||||
feedRepository: mockFeedRepository,
|
||||
dietsRepository: mockDietRepository,
|
||||
);
|
||||
|
||||
const dietsPagination = DietsPagination(
|
||||
data: [],
|
||||
links: Links(
|
||||
first: 'https://example.com?page=1',
|
||||
last: 'https://example.com?page=1',
|
||||
prev: null,
|
||||
next: null,
|
||||
),
|
||||
meta: Meta(
|
||||
currentPage: 1,
|
||||
from: null,
|
||||
lastPage: 1,
|
||||
links: [],
|
||||
perPage: 10,
|
||||
to: null,
|
||||
total: 0,
|
||||
),
|
||||
);
|
||||
|
||||
when(mockFeedRepository.getDiets(
|
||||
query: anyNamed('query'),
|
||||
types: anyNamed('types'),
|
||||
duration: anyNamed('duration'),
|
||||
sorting: anyNamed('sorting'),
|
||||
)).thenAnswer((_) async => dietsPagination);
|
||||
|
||||
dietsCubit!.currentPage = 4;
|
||||
await dietsCubit!.loadDiets();
|
||||
|
||||
expect(dietsCubit!.currentPage, 1);
|
||||
});
|
||||
|
||||
test('loadDiets - обработка ошибки', () async {
|
||||
dietsCubit = DietsCubit(
|
||||
feedRepository: mockFeedRepository,
|
||||
|
||||
@@ -131,6 +131,31 @@ void main() {
|
||||
expect(plansCubit!.state.errorMessage, isNull);
|
||||
});
|
||||
|
||||
test('loadPlans - сбрасывает currentPage при повторной загрузке', () async {
|
||||
plansCubit = PlansCubit(feedRepository: mockFeedRepository, planRepository: mockPlanRepository);
|
||||
|
||||
const plansPagination = PlanPaginationModel(
|
||||
data: [],
|
||||
links: Links(first: 'https://example.com?page=1', last: 'https://example.com?page=1', prev: null, next: null),
|
||||
meta: Meta(currentPage: 1, from: 1, lastPage: 1, links: [], perPage: 10, to: 1, total: 0),
|
||||
);
|
||||
|
||||
when(
|
||||
mockFeedRepository.getPlans(
|
||||
query: anyNamed('query'),
|
||||
types: anyNamed('types'),
|
||||
duration: anyNamed('duration'),
|
||||
minPrice: anyNamed('minPrice'),
|
||||
maxPrice: anyNamed('maxPrice'),
|
||||
),
|
||||
).thenAnswer((_) async => plansPagination);
|
||||
|
||||
plansCubit!.currentPage = 4;
|
||||
await plansCubit!.loadPlans();
|
||||
|
||||
expect(plansCubit!.currentPage, 1);
|
||||
});
|
||||
|
||||
test('loadPlans - обработка ошибки', () async {
|
||||
plansCubit = PlansCubit(feedRepository: mockFeedRepository, planRepository: mockPlanRepository);
|
||||
|
||||
|
||||
@@ -110,6 +110,18 @@ void main() {
|
||||
expect(recipesCubit!.state.errorMessage, isNull);
|
||||
});
|
||||
|
||||
test('loadRecipesList - сбрасывает currentPage при повторной загрузке', () async {
|
||||
recipesCubit = RecipesCubit(feedRepository: mockFeedRepository, recipeRepository: mockRecipeRepository, aboutRecipeRepository: mockAboutRecipeRepository);
|
||||
|
||||
when(mockFeedRepository.getRecipes(tags: anyNamed('tags'), query: anyNamed('query'))).thenAnswer((_) async => []);
|
||||
when(mockRecipeRepository.getRecipesTags()).thenAnswer((_) async => []);
|
||||
|
||||
recipesCubit!.currentPage = 4;
|
||||
await recipesCubit!.loadRecipesList();
|
||||
|
||||
expect(recipesCubit!.currentPage, 1);
|
||||
});
|
||||
|
||||
test('updateTags - добавляет и удаляет теги', () {
|
||||
recipesCubit = RecipesCubit(feedRepository: mockFeedRepository, recipeRepository: mockRecipeRepository, aboutRecipeRepository: mockAboutRecipeRepository);
|
||||
|
||||
|
||||
@@ -136,4 +136,19 @@ void main() {
|
||||
expect(find.byType(FeedScreen), findsOneWidget);
|
||||
});
|
||||
});
|
||||
|
||||
group('shouldReloadFeedAfterCreate', () {
|
||||
test('перезагружает ленту после публикации диеты или плана', () {
|
||||
expect(shouldReloadFeedAfterCreate(true), isTrue);
|
||||
});
|
||||
|
||||
test('перезагружает ленту после публикации рецепта', () {
|
||||
expect(shouldReloadFeedAfterCreate('active'), isTrue);
|
||||
});
|
||||
|
||||
test('не перезагружает ленту при отмене или черновике рецепта', () {
|
||||
expect(shouldReloadFeedAfterCreate(null), isFalse);
|
||||
expect(shouldReloadFeedAfterCreate('drafted'), isFalse);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:eatvio_app/core/app_config.dart';
|
||||
import 'package:eatvio_app/core/app_initializer.dart';
|
||||
import 'package:eatvio_app/core/locator_service.dart';
|
||||
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
||||
import 'package:eatvio_app/core/widgets/loader.dart';
|
||||
import 'package:eatvio_app/features/profile/models/profile_model.dart';
|
||||
import 'package:eatvio_app/features/settings/entities/settings_entity.dart';
|
||||
import 'package:eatvio_app/features/settings/models/profile_settings_model.dart';
|
||||
@@ -130,5 +133,52 @@ void main() {
|
||||
|
||||
expect(find.byType(GeneralInfoScreen), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('лоадер при сохранении появляется вместо кнопки, а не поверх экрана', (tester) async {
|
||||
final cubit = MockSettingsCubit();
|
||||
const state = SettingsState.loaded(
|
||||
profileSettings: mockProfileSettings,
|
||||
profileDetails: mockProfileDetails,
|
||||
profile: mockProfile,
|
||||
hasActiveConsultation: false,
|
||||
);
|
||||
final saveCompleter = Completer<void>();
|
||||
|
||||
when(cubit.state).thenReturn(state);
|
||||
when(cubit.stream).thenAnswer((_) => Stream.value(state));
|
||||
when(cubit.close()).thenAnswer((_) async => Future<void>.value());
|
||||
when(cubit.updateProfileDetails(profileDetailsModel: anyNamed('profileDetailsModel'))).thenAnswer((_) => saveCompleter.future);
|
||||
when(cubit.updateProfileSettings(profileSettingsModel: anyNamed('profileSettingsModel'))).thenAnswer((_) async {});
|
||||
|
||||
final settingsEntity = SettingsEntity(profileSettingsModel: mockProfileSettings, profileDetailsModel: mockProfileDetails, settingsCubit: cubit);
|
||||
|
||||
await tester.pumpWidget(
|
||||
BlocProvider<SettingsCubit>.value(
|
||||
value: cubit,
|
||||
child: wrap(GeneralInfoScreen(settingsEntity: settingsEntity)),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text('Сохранить'), findsOneWidget);
|
||||
expect(find.byType(Loader), findsNothing);
|
||||
|
||||
await tester.tap(find.text('Сохранить'));
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text('Сохранить'), findsNothing);
|
||||
expect(find.byType(Loader), findsOneWidget);
|
||||
expect(find.text('Введите имя'), findsOneWidget);
|
||||
|
||||
final loaderSize = tester.getSize(find.byType(Loader));
|
||||
expect(loaderSize.height, lessThan(60));
|
||||
|
||||
saveCompleter.complete();
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 50));
|
||||
|
||||
expect(find.text('Сохранить'), findsOneWidget);
|
||||
expect(find.byType(Loader), findsNothing);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user