forked from eatvio/eatvio_app
Compare commits
29 Commits
chat_messa
...
video_call
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
61cded0081 | ||
|
|
df634fedd8 | ||
|
|
2dce28b196 | ||
| ae4f9d2e9b | |||
|
|
a00aae36cf | ||
|
|
27e98bdb44 | ||
|
|
8ec6d5b799 | ||
|
|
077cfb3a67 | ||
| c4f5cae36d | |||
|
|
b040c6e020 | ||
|
|
745e77f948 | ||
|
|
c6ea1d414d | ||
|
|
10af279d87 | ||
| d0879e556f | |||
|
|
8f933118a3 | ||
| 7dc7eab774 | |||
|
|
16a13488d1 | ||
|
|
de7bf8dc19 | ||
| eeb261df0a | |||
|
|
1b03a0d5e1 | ||
|
|
0208ed753a | ||
| 184f0417a7 | |||
|
|
6265e865e9 | ||
| 1550b7ea0b | |||
|
|
db10895ebb | ||
|
|
a7361d3ab1 | ||
| 1c54063a35 | |||
| efae7fc958 | |||
| caf45a9050 |
@@ -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.
|
||||
|
||||
@@ -24,11 +24,9 @@ class EatvioFcmMessagingService : FirebaseMessagingService() {
|
||||
return
|
||||
}
|
||||
|
||||
if (MainActivity.isInForeground) {
|
||||
Log.i(TAG, "App in foreground, deferring incoming call to Flutter handler")
|
||||
return
|
||||
}
|
||||
|
||||
// Data-only call pushes are delivered only to this service.
|
||||
// FlutterFirebaseMessagingService is removed, so the Dart listener does
|
||||
// not see them. Show the incoming UI here for both foreground and background.
|
||||
VideoCallPushBridge.showIncomingCall(this, message.data)
|
||||
}
|
||||
|
||||
|
||||
@@ -264,7 +264,7 @@ object VideoCallPushBridge {
|
||||
putString(CallkitConstants.EXTRA_CALLKIT_APP_NAME, "Итвио")
|
||||
putString(CallkitConstants.EXTRA_CALLKIT_HANDLE, conversationUuid)
|
||||
putString(CallkitConstants.EXTRA_CALLKIT_AVATAR, avatarUrl)
|
||||
putInt(CallkitConstants.EXTRA_CALLKIT_TYPE, 0)
|
||||
putInt(CallkitConstants.EXTRA_CALLKIT_TYPE, 1)
|
||||
putLong(CallkitConstants.EXTRA_CALLKIT_DURATION, 45000L)
|
||||
putString(CallkitConstants.EXTRA_CALLKIT_TEXT_ACCEPT, "Answer")
|
||||
putString(CallkitConstants.EXTRA_CALLKIT_TEXT_DECLINE, "Decline")
|
||||
|
||||
@@ -44,6 +44,8 @@ post_install do |installer|
|
||||
'$(inherited)',
|
||||
## dart: PermissionGroup.camera
|
||||
'PERMISSION_CAMERA=1',
|
||||
## dart: PermissionGroup.microphone
|
||||
'PERMISSION_MICROPHONE=1',
|
||||
## dart: PermissionGroup.photos
|
||||
'PERMISSION_PHOTOS=1',
|
||||
'PERMISSION_NOTIFICATIONS=1',
|
||||
|
||||
@@ -4,6 +4,7 @@ import Flutter
|
||||
import PushKit
|
||||
import UIKit
|
||||
import UserNotifications
|
||||
import WebRTC
|
||||
import YandexLoginSDK
|
||||
import flutter_callkit_incoming
|
||||
import flutter_foreground_task
|
||||
@@ -15,7 +16,11 @@ import flutter_foreground_task
|
||||
private let voipDiagnosticsStorageKey = "EatvioVoipDiagnostics"
|
||||
private let pendingAcceptedVoipCallKey = "EatvioPendingAcceptedVoipCall"
|
||||
private let voipDiagnosticsChannelName = "com.eatvioapp/voip_diagnostics"
|
||||
private let callAudioSessionChannelName = "com.eatvioapp/call_audio_session"
|
||||
private let appUrlScheme = "eatvio"
|
||||
/// CallKit is only the ringing UI. After accept the in-app call continues,
|
||||
/// so the audio session must be handed back to WebRTC when CallKit deactivates.
|
||||
private var resumeAudioAfterCallKitEnds = false
|
||||
|
||||
override func application(
|
||||
_ application: UIApplication,
|
||||
@@ -57,6 +62,27 @@ import flutter_foreground_task
|
||||
result(FlutterMethodNotImplemented)
|
||||
}
|
||||
}
|
||||
|
||||
let callAudioChannel = FlutterMethodChannel(
|
||||
name: callAudioSessionChannelName,
|
||||
binaryMessenger: controller.binaryMessenger
|
||||
)
|
||||
callAudioChannel.setMethodCallHandler { [weak self] call, result in
|
||||
guard let self else {
|
||||
result(nil)
|
||||
return
|
||||
}
|
||||
switch call.method {
|
||||
case "activate":
|
||||
self.activateInAppCallAudioSession()
|
||||
result(nil)
|
||||
case "deactivate":
|
||||
self.deactivateInAppCallAudioSession()
|
||||
result(nil)
|
||||
default:
|
||||
result(FlutterMethodNotImplemented)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return launched
|
||||
@@ -120,6 +146,35 @@ import flutter_foreground_task
|
||||
)
|
||||
}
|
||||
|
||||
/// Same derivation as [VideoCallKitId.iosUuid]: one CallKit UUID per message,
|
||||
/// so the next call in this chat is not rejected as a duplicate UUID.
|
||||
private func derivedCallKitId(conversationUuid: String, messageId: UInt64?) -> String? {
|
||||
guard let messageId, messageId > 0 else { return nil }
|
||||
let pattern =
|
||||
#"^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-)[0-9a-fA-F]{12}$"#
|
||||
guard let regex = try? NSRegularExpression(pattern: pattern) else { return nil }
|
||||
let range = NSRange(conversationUuid.startIndex..., in: conversationUuid)
|
||||
guard let match = regex.firstMatch(in: conversationUuid, range: range),
|
||||
let prefixRange = Range(match.range(at: 1), in: conversationUuid)
|
||||
else { return nil }
|
||||
let suffix = String(format: "%012llx", messageId)
|
||||
guard suffix.count == 12 else { return nil }
|
||||
return (String(conversationUuid[prefixRange]) + suffix).lowercased()
|
||||
}
|
||||
|
||||
private func voipMessageId(_ value: Any?) -> UInt64? {
|
||||
if let number = value as? NSNumber {
|
||||
let parsed = number.uint64Value
|
||||
return parsed > 0 ? parsed : nil
|
||||
}
|
||||
if let text = value as? String {
|
||||
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard let parsed = UInt64(trimmed), parsed > 0 else { return nil }
|
||||
return parsed
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private func conversationUuid(fromActionUrl actionUrl: String?) -> String? {
|
||||
guard
|
||||
let actionUrl = actionUrl,
|
||||
@@ -343,13 +398,16 @@ import flutter_foreground_task
|
||||
return
|
||||
}
|
||||
|
||||
let nameCaller = (dict["nameCaller"] as? String)
|
||||
let rawNameCaller = (dict["nameCaller"] as? String)
|
||||
?? (dict["counterpartName"] as? String)
|
||||
?? (dict["counterpart_name"] as? String)
|
||||
?? (dict["caller_name"] as? String)
|
||||
?? (dict["callerName"] as? String)
|
||||
?? (dict["caller"] as? String)
|
||||
?? ""
|
||||
let nameCaller = rawNameCaller.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
? "Итвио"
|
||||
: rawNameCaller.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let conversationUuid = (dict["conversation_uuid"] as? String)
|
||||
?? (dict["conversationUuid"] as? String)
|
||||
?? (dict["conversation_id"] as? String)
|
||||
@@ -358,26 +416,27 @@ import flutter_foreground_task
|
||||
?? conversationUuid(fromActionUrl: dict["action_url"] as? String)
|
||||
?? (dict["handle"] as? String)
|
||||
?? ""
|
||||
let callKitIdSource = [
|
||||
(key: "call_id", value: dict["call_id"] as? String),
|
||||
(key: "id", value: dict["id"] as? String),
|
||||
(key: "message_id", value: dict["message_id"] as? String),
|
||||
]
|
||||
.first { item in
|
||||
guard let trimmed = item.value?.trimmingCharacters(in: .whitespacesAndNewlines) else {
|
||||
return false
|
||||
}
|
||||
return !trimmed.isEmpty && UUID(uuidString: trimmed) != nil
|
||||
}
|
||||
let id = callKitIdSource?.value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? UUID().uuidString
|
||||
let callKitIdSourceName = callKitIdSource?.key ?? "generated"
|
||||
let trimmedConversationUuid = conversationUuid.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let parsedMessageId = voipMessageId(dict["message_id"])
|
||||
let id: String
|
||||
let callKitIdSourceName: String
|
||||
if let derived = derivedCallKitId(
|
||||
conversationUuid: trimmedConversationUuid,
|
||||
messageId: parsedMessageId
|
||||
) {
|
||||
id = derived
|
||||
callKitIdSourceName = "conversation_and_message"
|
||||
} else {
|
||||
id = UUID().uuidString
|
||||
callKitIdSourceName = "generated"
|
||||
}
|
||||
let isVideo = (dict["isVideo"] as? Bool)
|
||||
?? (dict["is_video"] as? Bool)
|
||||
?? true
|
||||
|
||||
var extra: [String: Any] = [:]
|
||||
if !conversationUuid.isEmpty {
|
||||
extra["conversationUuid"] = conversationUuid
|
||||
if !trimmedConversationUuid.isEmpty {
|
||||
extra["conversationUuid"] = trimmedConversationUuid
|
||||
}
|
||||
if let avatar = dict["caller_avatar"] as? String ?? dict["avatar"] as? String
|
||||
?? dict["avatarUrl"] as? String
|
||||
@@ -409,9 +468,10 @@ import flutter_foreground_task
|
||||
let data = flutter_callkit_incoming.Data(
|
||||
id: id,
|
||||
nameCaller: nameCaller,
|
||||
handle: conversationUuid.isEmpty ? nameCaller : conversationUuid,
|
||||
handle: trimmedConversationUuid.isEmpty ? nameCaller : trimmedConversationUuid,
|
||||
type: isVideo ? 1 : 0
|
||||
)
|
||||
data.duration = 45000
|
||||
data.extra = extra as NSDictionary
|
||||
|
||||
NSLog(
|
||||
@@ -482,6 +542,8 @@ import flutter_foreground_task
|
||||
|
||||
|
||||
func onAccept(_ call: Call, _ action: CXAnswerCallAction) {
|
||||
resumeAudioAfterCallKitEnds = true
|
||||
prepareCallAudioCategory()
|
||||
storePendingAcceptedCall(call)
|
||||
recordVoipDiagnostic(
|
||||
"callkit_accept_native",
|
||||
@@ -500,6 +562,7 @@ import flutter_foreground_task
|
||||
}
|
||||
|
||||
func onDecline(_ call: Call, _ action: CXEndCallAction) {
|
||||
resumeAudioAfterCallKitEnds = false
|
||||
action.fulfill()
|
||||
}
|
||||
|
||||
@@ -509,9 +572,71 @@ import flutter_foreground_task
|
||||
|
||||
func onTimeOut(_ call: Call) {}
|
||||
|
||||
func didActivateAudioSession(_ audioSession: AVAudioSession) {}
|
||||
func didActivateAudioSession(_ audioSession: AVAudioSession) {
|
||||
let rtcSession = RTCAudioSession.sharedInstance()
|
||||
rtcSession.audioSessionDidActivate(audioSession)
|
||||
rtcSession.isAudioEnabled = true
|
||||
}
|
||||
|
||||
func didDeactivateAudioSession(_ audioSession: AVAudioSession) {}
|
||||
func didDeactivateAudioSession(_ audioSession: AVAudioSession) {
|
||||
let rtcSession = RTCAudioSession.sharedInstance()
|
||||
rtcSession.audioSessionDidDeactivate(audioSession)
|
||||
guard resumeAudioAfterCallKitEnds else { return }
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self, self.resumeAudioAfterCallKitEnds else { return }
|
||||
self.activateInAppCallAudioSession()
|
||||
}
|
||||
}
|
||||
|
||||
func providerDidReset() {}
|
||||
func providerDidReset() {
|
||||
resumeAudioAfterCallKitEnds = false
|
||||
}
|
||||
|
||||
private func prepareCallAudioCategory() {
|
||||
let rtcSession = RTCAudioSession.sharedInstance()
|
||||
rtcSession.lockForConfiguration()
|
||||
defer { rtcSession.unlockForConfiguration() }
|
||||
let configuration = callAudioConfiguration()
|
||||
do {
|
||||
try rtcSession.setConfiguration(configuration)
|
||||
} catch {
|
||||
NSLog("[EatvioVoIP] prepare call audio category failed: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
private func activateInAppCallAudioSession() {
|
||||
let rtcSession = RTCAudioSession.sharedInstance()
|
||||
rtcSession.lockForConfiguration()
|
||||
defer { rtcSession.unlockForConfiguration() }
|
||||
do {
|
||||
try rtcSession.setConfiguration(callAudioConfiguration(), active: true)
|
||||
rtcSession.isAudioEnabled = true
|
||||
} catch {
|
||||
NSLog("[EatvioVoIP] activate call audio session failed: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
private func deactivateInAppCallAudioSession() {
|
||||
resumeAudioAfterCallKitEnds = false
|
||||
let rtcSession = RTCAudioSession.sharedInstance()
|
||||
rtcSession.isAudioEnabled = false
|
||||
rtcSession.lockForConfiguration()
|
||||
defer { rtcSession.unlockForConfiguration() }
|
||||
do {
|
||||
try rtcSession.setActive(false)
|
||||
} catch {
|
||||
NSLog("[EatvioVoIP] deactivate call audio session failed: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
private func callAudioConfiguration() -> RTCAudioSessionConfiguration {
|
||||
let configuration = RTCAudioSessionConfiguration.webRTC()
|
||||
configuration.category = AVAudioSession.Category.playAndRecord.rawValue
|
||||
configuration.categoryOptions = [
|
||||
.allowBluetooth,
|
||||
.defaultToSpeaker,
|
||||
]
|
||||
configuration.mode = AVAudioSession.Mode.voiceChat.rawValue
|
||||
return configuration
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -122,7 +122,7 @@ class CubitRegistrations {
|
||||
|
||||
// Content viewing cubits
|
||||
sl.registerFactory<AboutDietCubit>(() => AboutDietCubit(dietsRepository: sl(), profileRepository: sl()));
|
||||
sl.registerFactory<AboutPlanCubit>(() => AboutPlanCubit(planRepository: sl()));
|
||||
sl.registerFactory<AboutPlanCubit>(() => AboutPlanCubit(planRepository: sl(), profileRepository: sl(), dietRepository: sl()));
|
||||
sl.registerFactory<AboutRecipeCubit>(() => AboutRecipeCubit(recipeRepository: sl(), profileRepository: sl()));
|
||||
sl.registerFactory<BlogPostCubit>(() => BlogPostCubit(sl()));
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
61
lib/core/utils/inline_svg_css.dart
Normal file
61
lib/core/utils/inline_svg_css.dart
Normal file
@@ -0,0 +1,61 @@
|
||||
/// flutter_svg / vector_graphics ignore `<style>` blocks.
|
||||
/// Paths then get the SVG default fill (black), so badges look like a black blob.
|
||||
/// This inlines class rules from `<style>` as attributes before parsing.
|
||||
String inlineSvgCss(String svg) {
|
||||
final styleBlockPattern = RegExp(
|
||||
r'<style[^>]*>(?:<!\[CDATA\[)?([\s\S]*?)(?:\]\]>)?</style>',
|
||||
caseSensitive: false,
|
||||
);
|
||||
|
||||
final classProps = <String, Map<String, String>>{};
|
||||
for (final match in styleBlockPattern.allMatches(svg)) {
|
||||
final css = match.group(1) ?? '';
|
||||
final rulePattern = RegExp(r'\.([A-Za-z0-9_-]+)\s*\{([^}]*)\}');
|
||||
for (final rule in rulePattern.allMatches(css)) {
|
||||
final className = rule.group(1)!;
|
||||
final body = rule.group(2)!;
|
||||
final props = classProps.putIfAbsent(className, () => <String, String>{});
|
||||
for (final declaration in body.split(';')) {
|
||||
final colon = declaration.indexOf(':');
|
||||
if (colon <= 0) {
|
||||
continue;
|
||||
}
|
||||
final key = declaration.substring(0, colon).trim();
|
||||
final value = declaration.substring(colon + 1).trim();
|
||||
if (key.isNotEmpty && value.isNotEmpty) {
|
||||
props[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var result = svg.replaceAll(styleBlockPattern, '');
|
||||
if (classProps.isEmpty) {
|
||||
return result;
|
||||
}
|
||||
|
||||
result = result.replaceAllMapped(RegExp(r'''\sclass=(["'])([^"']+)\1'''), (
|
||||
Match match,
|
||||
) {
|
||||
final classes = match
|
||||
.group(2)!
|
||||
.split(RegExp(r'\s+'))
|
||||
.where((name) => name.isNotEmpty);
|
||||
final attrs = <String, String>{};
|
||||
for (final className in classes) {
|
||||
final props = classProps[className];
|
||||
if (props != null) {
|
||||
attrs.addAll(props);
|
||||
}
|
||||
}
|
||||
if (attrs.isEmpty) {
|
||||
return match.group(0)!;
|
||||
}
|
||||
final attrString = attrs.entries
|
||||
.map((entry) => '${entry.key}="${entry.value}"')
|
||||
.join(' ');
|
||||
return ' $attrString';
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -38,6 +38,13 @@ class WsManager {
|
||||
StreamSubscription? _eventSubscription;
|
||||
StreamSubscription? _connectionEstablishedSubscription;
|
||||
|
||||
/// Широковещательный сигнал о восстановлении соединения после обрыва (S5).
|
||||
///
|
||||
/// Контроллер создаётся один раз и не закрывается в [clear]: подписчики
|
||||
/// (чаты, окно чата) переживают логаут/повторный вход и продолжают получать
|
||||
/// сигналы. Если контроллер всё же закрыт, он пересоздаётся в [initialize].
|
||||
StreamController<void> _reconnectController = StreamController<void>.broadcast();
|
||||
|
||||
Stream<ChannelReadEvent>? _directChatStream;
|
||||
Stream<ChannelReadEvent>? _clientTypingStream;
|
||||
|
||||
@@ -49,7 +56,17 @@ class WsManager {
|
||||
|
||||
Stream<ChannelReadEvent>? get clientTypingStream => _clientTypingStream;
|
||||
|
||||
/// Сигнал о реконнекте сокета: эмитится после повторной подписки на каналы.
|
||||
///
|
||||
/// Подписчики используют его, чтобы догрузить пропущенные за время обрыва
|
||||
/// сообщения (S5).
|
||||
Stream<void> get reconnected => _reconnectController.stream;
|
||||
|
||||
Future<void> initialize() async {
|
||||
if (_reconnectController.isClosed) {
|
||||
_reconnectController = StreamController<void>.broadcast();
|
||||
}
|
||||
|
||||
if (client == null) {
|
||||
try {
|
||||
if (!await TokenStorage.haveTokenInStorage()) return;
|
||||
@@ -108,10 +125,7 @@ class WsManager {
|
||||
|
||||
_lifecycleSubscription = client?.lifecycleStream.listen((data) {
|
||||
if (data == PusherChannelsClientLifeCycleState.establishedConnection) {
|
||||
_unsubscribe();
|
||||
_subscribe();
|
||||
_privateDirectChatChannel?.subscribe();
|
||||
_presenceDirectChatChannel?.subscribe();
|
||||
handleConnectionEstablished();
|
||||
}
|
||||
});
|
||||
_eventSubscription = client?.eventStream.listen((event) {
|
||||
@@ -301,6 +315,19 @@ class WsManager {
|
||||
_privateNotificationChannel?.unsubscribe();
|
||||
}
|
||||
|
||||
/// Повторно подписывается на каналы и сигналит подписчикам о реконнекте.
|
||||
///
|
||||
/// Вызывается при `establishedConnection` — в том числе после обрыва сети.
|
||||
/// Публичный, чтобы сигнал [reconnected] покрывался тестами без реального
|
||||
/// Pusher-клиента.
|
||||
void handleConnectionEstablished() {
|
||||
_unsubscribe();
|
||||
_subscribe();
|
||||
_privateDirectChatChannel?.subscribe();
|
||||
_presenceDirectChatChannel?.subscribe();
|
||||
_reconnectController.add(null);
|
||||
}
|
||||
|
||||
void subscribeDirectChatChannel() {
|
||||
_privateDirectChatChannel?.subscribe();
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ import 'package:share_plus/share_plus.dart';
|
||||
|
||||
class CustomPopup extends StatelessWidget {
|
||||
final bool isAuthor;
|
||||
final bool isAuthorAdmin;
|
||||
final ComplainedContent? complainedContentType;
|
||||
final String path;
|
||||
final String? slug;
|
||||
@@ -38,6 +39,7 @@ class CustomPopup extends StatelessWidget {
|
||||
const CustomPopup({
|
||||
super.key,
|
||||
required this.isAuthor,
|
||||
this.isAuthorAdmin = false,
|
||||
this.complainedContentType,
|
||||
required this.path,
|
||||
this.id,
|
||||
@@ -49,6 +51,9 @@ class CustomPopup extends StatelessWidget {
|
||||
this.onPostsChanged,
|
||||
});
|
||||
|
||||
bool get _isAdminAuthor =>
|
||||
isAuthorAdmin || (diet?.author.isAdmin ?? false) || (recipe?.author.isAdmin ?? false) || (feedBlogPostModel?.author.isAdmin ?? false);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final s = S.of(context);
|
||||
@@ -167,19 +172,21 @@ class CustomPopup extends StatelessWidget {
|
||||
),
|
||||
);
|
||||
});
|
||||
addItem('assets/icons/complain.svg', 'Пожаловаться', () {
|
||||
if (complainedContentType != null) {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
builder: (context) => ComplainingBottomSheet(
|
||||
complainedContentType: complainedContentType!,
|
||||
id: id,
|
||||
slug: slug,
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
if (!_isAdminAuthor) {
|
||||
addItem('assets/icons/complain.svg', 'Пожаловаться', () {
|
||||
if (complainedContentType != null) {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
builder: (context) => ComplainingBottomSheet(
|
||||
complainedContentType: complainedContentType!,
|
||||
id: id,
|
||||
slug: slug,
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
addItem(
|
||||
'assets/icons/complain.svg',
|
||||
'Заблокировать автора',
|
||||
|
||||
57
lib/core/widgets/network_svg_picture.dart
Normal file
57
lib/core/widgets/network_svg_picture.dart
Normal file
@@ -0,0 +1,57 @@
|
||||
import 'package:eatvio_app/core/utils/inline_svg_css.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_svg/flutter_svg.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
final Map<String, String> _inlinedSvgCache = <String, String>{};
|
||||
|
||||
class NetworkSvgPicture extends StatefulWidget {
|
||||
const NetworkSvgPicture({
|
||||
super.key,
|
||||
required this.url,
|
||||
this.width,
|
||||
this.height,
|
||||
});
|
||||
|
||||
final String url;
|
||||
final double? width;
|
||||
final double? height;
|
||||
|
||||
@override
|
||||
State<NetworkSvgPicture> createState() => _NetworkSvgPictureState();
|
||||
}
|
||||
|
||||
class _NetworkSvgPictureState extends State<NetworkSvgPicture> {
|
||||
late final Future<String> _svgFuture = _load(widget.url);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FutureBuilder<String>(
|
||||
future: _svgFuture,
|
||||
builder: (BuildContext context, AsyncSnapshot<String> snapshot) {
|
||||
if (!snapshot.hasData) {
|
||||
return SizedBox(width: widget.width, height: widget.height);
|
||||
}
|
||||
return SvgPicture.string(
|
||||
snapshot.data!,
|
||||
width: widget.width,
|
||||
height: widget.height,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
static Future<String> _load(String url) async {
|
||||
final cached = _inlinedSvgCache[url];
|
||||
if (cached != null) {
|
||||
return cached;
|
||||
}
|
||||
final response = await http.get(Uri.parse(url));
|
||||
if (response.statusCode < 200 || response.statusCode >= 300) {
|
||||
throw Exception('Failed to load SVG from $url');
|
||||
}
|
||||
final processed = inlineSvgCss(response.body);
|
||||
_inlinedSvgCache[url] = processed;
|
||||
return processed;
|
||||
}
|
||||
}
|
||||
@@ -198,6 +198,7 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
|
||||
actions: [
|
||||
CustomPopup(
|
||||
isAuthor: blogPostState.blogPostModel?.author.authorIsMe ?? false,
|
||||
isAuthorAdmin: blogPostState.blogPostModel?.author.isAdmin ?? false,
|
||||
complainedContentType: ComplainedContent.posts,
|
||||
path: 'blog_post/${(widget.id)}',
|
||||
id: widget.id,
|
||||
|
||||
@@ -37,6 +37,17 @@ class BlogPostCreateCubit extends Cubit<BlogPostCreateState> {
|
||||
}
|
||||
}
|
||||
|
||||
void setImagesError(String? error) {
|
||||
if (state.imagesError == error &&
|
||||
state.formError == null &&
|
||||
state.errorMessage == null) {
|
||||
return;
|
||||
}
|
||||
emit(
|
||||
state.copyWith(imagesError: error, formError: null, errorMessage: null),
|
||||
);
|
||||
}
|
||||
|
||||
void resetFormErrors() {
|
||||
if (!state.hasValidationErrors &&
|
||||
!state.isSuccess &&
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:eatvio_app/app_keys.dart';
|
||||
import 'package:eatvio_app/core/app_router.dart';
|
||||
@@ -38,11 +39,15 @@ class BlogPostCreateScreen extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _BlogPostCreateScreenState extends State<BlogPostCreateScreen> {
|
||||
static const _minImageWidth = 500;
|
||||
static const _minImageHeight = 500;
|
||||
|
||||
final blogPostCubit = sl<BlogPostCreateCubit>();
|
||||
bool shrinkImage = false;
|
||||
late StreamSubscription<bool> keyboardSubscription;
|
||||
final List<FeedGalleryImage> savedImages = [];
|
||||
final List<XFile> pendingImages = [];
|
||||
final Set<String> _undersizedPendingPaths = {};
|
||||
final List<int> mediaIdsToDelete = [];
|
||||
final titleController = TextEditingController();
|
||||
final descriptionController = TextEditingController();
|
||||
@@ -531,10 +536,7 @@ class _BlogPostCreateScreenState extends State<BlogPostCreateScreen> {
|
||||
source: ImageSource.camera,
|
||||
);
|
||||
if (image != null && _remainingSlots > 0) {
|
||||
setState(() {
|
||||
pendingImages.add(image);
|
||||
});
|
||||
blogPostCubit.clearImagesError();
|
||||
await _addPendingImages([image]);
|
||||
}
|
||||
AppRouter.navigatorKey.currentContext?.pop();
|
||||
},
|
||||
@@ -561,10 +563,9 @@ class _BlogPostCreateScreenState extends State<BlogPostCreateScreen> {
|
||||
limit: _remainingSlots,
|
||||
);
|
||||
if (images.isNotEmpty) {
|
||||
setState(() {
|
||||
pendingImages.addAll(images.take(_remainingSlots));
|
||||
});
|
||||
blogPostCubit.clearImagesError();
|
||||
await _addPendingImages(
|
||||
images.take(_remainingSlots).toList(),
|
||||
);
|
||||
}
|
||||
if (!context.mounted) return;
|
||||
AppRouter.navigatorKey.currentContext?.pop();
|
||||
@@ -603,9 +604,12 @@ class _BlogPostCreateScreenState extends State<BlogPostCreateScreen> {
|
||||
}
|
||||
|
||||
void _removePendingImage(int index) {
|
||||
final path = pendingImages[index].path;
|
||||
setState(() {
|
||||
pendingImages.removeAt(index);
|
||||
_undersizedPendingPaths.remove(path);
|
||||
});
|
||||
_syncImagesSizeError();
|
||||
}
|
||||
|
||||
void _onReorderGallery(int oldIndex, int newIndex) {
|
||||
@@ -759,7 +763,6 @@ class _BlogPostCreateScreenState extends State<BlogPostCreateScreen> {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const SizedBox(height: 16),
|
||||
const Text('Заголовок'),
|
||||
const SizedBox(height: 8),
|
||||
CustomTextFormField(
|
||||
@@ -839,6 +842,11 @@ class _BlogPostCreateScreenState extends State<BlogPostCreateScreen> {
|
||||
return;
|
||||
}
|
||||
|
||||
if (_undersizedPendingPaths.isNotEmpty) {
|
||||
blogPostCubit.setImagesError(S.current.blog_post_image_min_size_error);
|
||||
return;
|
||||
}
|
||||
|
||||
final title = titleController.text.trim();
|
||||
final description = descriptionController.text.trim();
|
||||
final videoUrl = videoUrlController.text.trim();
|
||||
@@ -907,6 +915,50 @@ class _BlogPostCreateScreenState extends State<BlogPostCreateScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _addPendingImages(List<XFile> images) async {
|
||||
if (images.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
final undersizedPaths = <String>{};
|
||||
for (final image in images) {
|
||||
if (!await _isImageLargeEnough(image)) {
|
||||
undersizedPaths.add(image.path);
|
||||
}
|
||||
}
|
||||
|
||||
setState(() {
|
||||
pendingImages.addAll(images);
|
||||
_undersizedPendingPaths.addAll(undersizedPaths);
|
||||
});
|
||||
_syncImagesSizeError();
|
||||
}
|
||||
|
||||
void _syncImagesSizeError() {
|
||||
final minSizeError = S.current.blog_post_image_min_size_error;
|
||||
if (_undersizedPendingPaths.isNotEmpty) {
|
||||
blogPostCubit.setImagesError(minSizeError);
|
||||
return;
|
||||
}
|
||||
if (blogPostCubit.state.imagesError == minSizeError) {
|
||||
blogPostCubit.clearImagesError();
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> _isImageLargeEnough(XFile image) async {
|
||||
try {
|
||||
final bytes = await File(image.path).readAsBytes();
|
||||
final descriptor = await ui.ImmutableBuffer.fromUint8List(bytes);
|
||||
final codec = await ui.instantiateImageCodecFromBuffer(descriptor);
|
||||
final frame = await codec.getNextFrame();
|
||||
final width = frame.image.width;
|
||||
final height = frame.image.height;
|
||||
return width >= _minImageWidth && height >= _minImageHeight;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildGallery() {
|
||||
final itemHeight = shrinkImage ? 72.0 : 112.0;
|
||||
|
||||
@@ -946,6 +998,7 @@ class _BlogPostCreateScreenState extends State<BlogPostCreateScreen> {
|
||||
child: _buildGalleryTile(
|
||||
index: index,
|
||||
height: itemHeight,
|
||||
hasError: _undersizedPendingPaths.contains(pending.path),
|
||||
imageProvider: FileImage(File(pending.path)),
|
||||
onRemove: () => _removePendingImage(pendingIndex),
|
||||
),
|
||||
@@ -985,6 +1038,7 @@ class _BlogPostCreateScreenState extends State<BlogPostCreateScreen> {
|
||||
required double height,
|
||||
required ImageProvider imageProvider,
|
||||
required VoidCallback onRemove,
|
||||
bool hasError = false,
|
||||
}) {
|
||||
return ReorderableDelayedDragStartListener(
|
||||
index: index,
|
||||
@@ -992,12 +1046,16 @@ class _BlogPostCreateScreenState extends State<BlogPostCreateScreen> {
|
||||
width: height,
|
||||
height: height,
|
||||
margin: const EdgeInsets.only(right: 8),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: hasError ? Border.all(color: appTheme.red, width: 2) : null,
|
||||
),
|
||||
child: Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
child: Image(image: imageProvider, fit: BoxFit.cover),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -31,6 +31,11 @@ abstract class ChatMessageModel with _$ChatMessageModel {
|
||||
@JsonKey(name: 'author_id', fromJson: _parseAuthorId) required int authorId,
|
||||
@JsonKey(name: 'updated_at', fromJson: _parseUpdatedAt) required DateTime updatedAt,
|
||||
@JsonKey(name: 'read_by_to_user', defaultValue: false) required bool isRead,
|
||||
// @deprecated Backend BC-поле. В broadcast (ChatMessageUpdated) оно
|
||||
// recipient-relative и для второго устройства отправителя всегда false,
|
||||
// поэтому решать «моё/не моё» по нему нельзя. Источник истины — authorId:
|
||||
// сравнивайте его со своим user_id (wsManager.userId). Поле оставлено
|
||||
// только для обратной совместимости парсинга.
|
||||
@JsonKey(name: 'is_author_message_auth_user') required bool? isCurrentUserAuthor,
|
||||
@JsonKey(name: 'room_id') required String? roomId,
|
||||
@JsonKey(name: 'type', unknownEnumValue: ChatMessageType.unknown) ChatMessageType? type,
|
||||
|
||||
@@ -31,6 +31,7 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
|
||||
|
||||
StreamSubscription<ChannelReadEvent>? typingSubscription;
|
||||
StreamSubscription<ChannelReadEvent>? messagesSubscription;
|
||||
StreamSubscription<void>? _reconnectSubscription;
|
||||
int currentPage = 1;
|
||||
late int lastPage;
|
||||
bool isLoading = false;
|
||||
@@ -39,11 +40,18 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
|
||||
void Function(bool isOnline)? _presenceOnlineListener;
|
||||
var uuid = const Uuid();
|
||||
|
||||
ChatRoomCubit({required this.chatRepository, required this.wsManager, required this.cardsRepository}) : super(const ChatRoomState.loading());
|
||||
ChatRoomCubit({
|
||||
required this.chatRepository,
|
||||
required this.wsManager,
|
||||
required this.cardsRepository,
|
||||
}) : super(const ChatRoomState.loading());
|
||||
|
||||
void setTimer() {
|
||||
_timer?.cancel();
|
||||
_timer = Timer.periodic(const Duration(seconds: 5), (_) => updateChosenPlanState());
|
||||
_timer = Timer.periodic(
|
||||
const Duration(seconds: 5),
|
||||
(_) => updateChosenPlanState(),
|
||||
);
|
||||
}
|
||||
|
||||
void cancelTimer() {
|
||||
@@ -57,13 +65,19 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
|
||||
_emitLoaded((current) => current.copyWith(isLoading: isLoading));
|
||||
|
||||
try {
|
||||
final chatMessages = await chatRepository.getChatMessages(uuid, currentPage + 1);
|
||||
final chatMessages = await chatRepository.getChatMessages(
|
||||
uuid,
|
||||
currentPage + 1,
|
||||
);
|
||||
|
||||
isLoading = false;
|
||||
_emitLoaded(
|
||||
(current) => current.copyWith(
|
||||
isLoading: isLoading,
|
||||
chatMessages: [...current.chatMessages, ...chatMessages.messagesList],
|
||||
chatMessages: [
|
||||
...current.chatMessages,
|
||||
...chatMessages.messagesList,
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -97,7 +111,11 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
|
||||
/// `GET /api/consultation/resolve/order_id_to_conversation/{orderId}`.
|
||||
///
|
||||
/// Возвращает conversation uuid, которым инициализировали чат (для reload без повторного resolve).
|
||||
Future<String?> initChat(String? uuid, {String? orderId, bool sendInitialMessage = false}) async {
|
||||
Future<String?> initChat(
|
||||
String? uuid, {
|
||||
String? orderId,
|
||||
bool sendInitialMessage = false,
|
||||
}) async {
|
||||
try {
|
||||
emit(const ChatRoomState.loading());
|
||||
|
||||
@@ -105,7 +123,9 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
|
||||
int? resolvedChosenPlanId;
|
||||
|
||||
if (orderId != null && orderId.isNotEmpty) {
|
||||
final resolved = await chatRepository.resolveOrderIdToConversation(orderId: orderId);
|
||||
final resolved = await chatRepository.resolveOrderIdToConversation(
|
||||
orderId: orderId,
|
||||
);
|
||||
resolvedUuid = resolved.conversationUuid;
|
||||
resolvedChosenPlanId = resolved.chosenPlanId;
|
||||
}
|
||||
@@ -116,6 +136,7 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
|
||||
|
||||
currentPage = 1;
|
||||
isLoading = false;
|
||||
_activeChatUuid = resolvedUuid;
|
||||
|
||||
final chatRoom = await chatRepository.getChatRoom(resolvedUuid);
|
||||
ChosenPlanModel? chosenPlan;
|
||||
@@ -123,9 +144,13 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
|
||||
if (planId != null) {
|
||||
chosenPlan = await chatRepository.getChosenPlan(id: planId);
|
||||
}
|
||||
final chatMessages = await chatRepository.getChatMessages(resolvedUuid, currentPage);
|
||||
final chatMessages = await chatRepository.getChatMessages(
|
||||
resolvedUuid,
|
||||
currentPage,
|
||||
);
|
||||
lastPage = chatMessages.meta.lastPage;
|
||||
await _subscribeToChatChannel(uuid: resolvedUuid, chatId: chatRoom.id);
|
||||
_listenToReconnect();
|
||||
emit(
|
||||
ChatRoomState.loaded(
|
||||
chatRoom: chatRoom.copyWith(chosenPlan: chosenPlan),
|
||||
@@ -146,12 +171,94 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
|
||||
log(e.toString());
|
||||
typingSubscription?.cancel();
|
||||
messagesSubscription?.cancel();
|
||||
_reconnectSubscription?.cancel();
|
||||
_reconnectSubscription = null;
|
||||
wsManager.unsubscribeDirectChatChannel();
|
||||
emit(const ChatRoomState.error(errorMessage: 'Ошибка получеия данных'));
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Подписывается на реконнект сокета, чтобы догрузить пропущенное (S5).
|
||||
void _listenToReconnect() {
|
||||
_reconnectSubscription?.cancel();
|
||||
_reconnectSubscription = wsManager.reconnected.listen((_) {
|
||||
unawaited(refreshCurrentChat());
|
||||
});
|
||||
}
|
||||
|
||||
/// Догружает первую страницу сообщений после восстановления соединения (S5).
|
||||
///
|
||||
/// Мерж по `id` сохраняет уже подгруженные старые страницы: пропущенные
|
||||
/// сообщения добавляются, существующие не дублируются. Порядок — по
|
||||
/// `updatedAt` по убыванию, как его ждёт UI и отдаёт backend (S1).
|
||||
Future<void> refreshCurrentChat() async {
|
||||
final uuid = _activeChatUuid;
|
||||
if (uuid == null) return;
|
||||
|
||||
try {
|
||||
final page = await chatRepository.getChatMessages(uuid, 1);
|
||||
lastPage = page.meta.lastPage;
|
||||
_emitLoaded((current) {
|
||||
final byId = {
|
||||
for (final message in current.chatMessages) message.id: message,
|
||||
};
|
||||
for (final message in page.messagesList) {
|
||||
byId.putIfAbsent(message.id, () => message);
|
||||
}
|
||||
final merged = byId.values.toList()
|
||||
..sort((a, b) => b.updatedAt.compareTo(a.updatedAt));
|
||||
return current.copyWith(chatMessages: merged);
|
||||
});
|
||||
} catch (e, st) {
|
||||
await reportFrom(e, st, this);
|
||||
}
|
||||
}
|
||||
|
||||
/// Перезагружает сообщения, комнату и состояние плана без экрана загрузки.
|
||||
Future<void> refreshChat() async {
|
||||
final uuid = _activeChatUuid;
|
||||
if (uuid == null || uuid.isEmpty) {
|
||||
return;
|
||||
}
|
||||
final currentState = state;
|
||||
if (currentState is! ChatRoomStateLoaded) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
currentPage = 1;
|
||||
isLoading = false;
|
||||
|
||||
final chatRoom = await chatRepository.getChatRoom(uuid);
|
||||
ChosenPlanModel? chosenPlan;
|
||||
final planId =
|
||||
chatRoom.chosenPlanId ?? currentState.chatRoom.chosenPlanId;
|
||||
if (planId != null) {
|
||||
chosenPlan = await chatRepository.getChosenPlan(id: planId);
|
||||
}
|
||||
final chatMessages = await chatRepository.getChatMessages(
|
||||
uuid,
|
||||
currentPage,
|
||||
);
|
||||
lastPage = chatMessages.meta.lastPage;
|
||||
|
||||
_emitLoaded(
|
||||
(latest) => latest.copyWith(
|
||||
chatRoom: chatRoom.copyWith(chosenPlan: chosenPlan),
|
||||
chatMessages: chatMessages.messagesList,
|
||||
isLoading: false,
|
||||
),
|
||||
);
|
||||
|
||||
if (chosenPlan?.consultantId == chatRoom.users.sender.id) {
|
||||
await checkCards();
|
||||
}
|
||||
} catch (e, st) {
|
||||
await reportFrom(e, st, this);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> updateChosenPlanState() async {
|
||||
try {
|
||||
final currentState = state;
|
||||
@@ -175,9 +282,16 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
|
||||
final planId = (state as ChatRoomStateLoaded).chatRoom.chosenPlanId;
|
||||
if (planId == null) return false;
|
||||
final stringDate = date.format('yyyy-MM-dd');
|
||||
await chatRepository.choseStartPlanDate(id: planId, date: {'start_date': stringDate});
|
||||
await chatRepository.choseStartPlanDate(
|
||||
id: planId,
|
||||
date: {'start_date': stringDate},
|
||||
);
|
||||
final chosenPlan = await chatRepository.getChosenPlan(id: planId);
|
||||
emit((state as ChatRoomStateLoaded).copyWith.chatRoom(chosenPlan: chosenPlan));
|
||||
emit(
|
||||
(state as ChatRoomStateLoaded).copyWith.chatRoom(
|
||||
chosenPlan: chosenPlan,
|
||||
),
|
||||
);
|
||||
return true;
|
||||
} catch (e, st) {
|
||||
await reportFrom(e, st, this);
|
||||
@@ -186,7 +300,10 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> cancelChosenPlan({required String chatId, required int planId}) async {
|
||||
Future<void> cancelChosenPlan({
|
||||
required String chatId,
|
||||
required int planId,
|
||||
}) async {
|
||||
try {
|
||||
await chatRepository.cancelChosenPlan(id: planId);
|
||||
emit((state as ChatRoomStateLoaded).copyWith.chatRoom(chosenPlan: null));
|
||||
@@ -196,7 +313,9 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
|
||||
}
|
||||
}
|
||||
|
||||
bool _emitLoaded(ChatRoomStateLoaded Function(ChatRoomStateLoaded current) update) {
|
||||
bool _emitLoaded(
|
||||
ChatRoomStateLoaded Function(ChatRoomStateLoaded current) update,
|
||||
) {
|
||||
if (isClosed) {
|
||||
return false;
|
||||
}
|
||||
@@ -208,10 +327,15 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
|
||||
return true;
|
||||
}
|
||||
|
||||
Future<void> _subscribeToChatChannel({required String uuid, required String chatId}) async {
|
||||
Future<void> _subscribeToChatChannel({
|
||||
required String uuid,
|
||||
required String chatId,
|
||||
}) async {
|
||||
// Активный чат запоминаем до проверки клиента: он нужен и для догрузки
|
||||
// пропущенного после реконнекта (S5), даже если сокет появится позже.
|
||||
_activeChatUuid = uuid;
|
||||
final client = wsManager.client;
|
||||
if (client == null) return;
|
||||
_activeChatUuid = uuid;
|
||||
await messagesSubscription?.cancel();
|
||||
await typingSubscription?.cancel();
|
||||
messagesSubscription = null;
|
||||
@@ -226,18 +350,26 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
|
||||
);
|
||||
|
||||
typingSubscription = wsManager.clientTypingStream?.listen((event) {
|
||||
_emitLoaded((current) => current.copyWith(isTyping: true, isLoading: isLoading));
|
||||
_emitLoaded(
|
||||
(current) => current.copyWith(isTyping: true, isLoading: isLoading),
|
||||
);
|
||||
EasyDebounce.debounce(
|
||||
'client-typing',
|
||||
const Duration(seconds: 3),
|
||||
() => _emitLoaded((current) => current.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>))) {
|
||||
if (addIncomingMessage(
|
||||
ChatMessageModel.fromJson(
|
||||
jsonDecode(event.data) as Map<String, dynamic>,
|
||||
),
|
||||
)) {
|
||||
unawaited(_playIncomingSound());
|
||||
}
|
||||
} catch (e, st) {
|
||||
@@ -252,22 +384,39 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
|
||||
}
|
||||
|
||||
/// Добавляет входящее сообщение в текущий список, не затирая более новый state.
|
||||
///
|
||||
/// Собственное сообщение, пришедшее по сокету, — это эхо с другого устройства
|
||||
/// автора (`author_id == wsManager.userId`). Такое сообщение тоже добавляется
|
||||
/// в открытый чат, но не считается входящим: звук не играет, а список чатов
|
||||
/// обновляет S3 через `new-chat-messages.{senderId}`.
|
||||
///
|
||||
/// Возвращает `true`, только если нужно проиграть звук входящего (чужое
|
||||
/// сообщение, реально добавленное).
|
||||
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)) {
|
||||
final currentUserId = wsManager.userId;
|
||||
final isOwn = currentUserId != null && message.authorId == currentUserId;
|
||||
|
||||
// Дедуп: по серверному id и по uuid (если broadcast вернул наш uuid, т.е.
|
||||
// это эхо уже добавленного оптимистичного сообщения).
|
||||
final alreadyExists = currentState.chatMessages.any(
|
||||
(existing) =>
|
||||
(message.id != 0 && existing.id == message.id) ||
|
||||
(message.uuid != null && existing.uuid == message.uuid),
|
||||
);
|
||||
if (alreadyExists) {
|
||||
return false;
|
||||
}
|
||||
|
||||
final added = _emitLoaded((current) {
|
||||
if (current.chatMessages.any((existing) => existing.id == message.id && message.id != 0)) {
|
||||
if (current.chatMessages.any(
|
||||
(existing) =>
|
||||
(message.id != 0 && existing.id == message.id) ||
|
||||
(message.uuid != null && existing.uuid == message.uuid),
|
||||
)) {
|
||||
return current;
|
||||
}
|
||||
return current.copyWith(
|
||||
@@ -278,8 +427,13 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
|
||||
if (!added) {
|
||||
return false;
|
||||
}
|
||||
chatRepository.emitNewMessageToChats(message);
|
||||
return true;
|
||||
|
||||
// Список чатов за собственные сообщения обновляет S3 (new-chat-messages.{senderId}).
|
||||
if (!isOwn) {
|
||||
chatRepository.emitNewMessageToChats(message);
|
||||
}
|
||||
|
||||
return !isOwn;
|
||||
}
|
||||
|
||||
Future<void> _subscribeToPresenceChannel({required String chatId}) async {
|
||||
@@ -293,12 +447,15 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
|
||||
|
||||
await wsManager.initPresenceDirectChatChannel(chatId);
|
||||
_presenceOnlineListener = (isOnline) {
|
||||
unawaited(_handleCounterpartOnlineChanged(
|
||||
counterpartField: counterpart.id == currentState.chatRoom.users.receiver.id
|
||||
? _ChatCounterpartField.receiver
|
||||
: _ChatCounterpartField.sender,
|
||||
isOnline: isOnline,
|
||||
));
|
||||
unawaited(
|
||||
_handleCounterpartOnlineChanged(
|
||||
counterpartField:
|
||||
counterpart.id == currentState.chatRoom.users.receiver.id
|
||||
? _ChatCounterpartField.receiver
|
||||
: _ChatCounterpartField.sender,
|
||||
isOnline: isOnline,
|
||||
),
|
||||
);
|
||||
};
|
||||
wsManager.addPresenceDirectChatListener(
|
||||
counterpartUserId: counterpart.id,
|
||||
@@ -317,7 +474,8 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
|
||||
? currentState.chatRoom.users.receiver
|
||||
: currentState.chatRoom.users.sender;
|
||||
|
||||
if (counterpart.isOnline == isOnline && (isOnline || counterpart.lastSeenText.isNotEmpty)) {
|
||||
if (counterpart.isOnline == isOnline &&
|
||||
(isOnline || counterpart.lastSeenText.isNotEmpty)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -394,10 +552,16 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
|
||||
);
|
||||
}
|
||||
|
||||
Future<bool> sendMessage({String? data, List<File>? files, required String userSlug}) async {
|
||||
Future<bool> sendMessage({
|
||||
String? data,
|
||||
List<File>? files,
|
||||
required String userSlug,
|
||||
}) async {
|
||||
try {
|
||||
final messageUuid = uuid.v4();
|
||||
final compressedFiles = files != null ? await ImageCompressUtility.compressFilesList(List<File>.from(files)) : null;
|
||||
final compressedFiles = files != null
|
||||
? await ImageCompressUtility.compressFilesList(List<File>.from(files))
|
||||
: null;
|
||||
try {
|
||||
unawaited(AudioPlayer().play(AssetSource('audio/send_message.wav')));
|
||||
} catch (_) {}
|
||||
@@ -423,8 +587,15 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
|
||||
);
|
||||
|
||||
try {
|
||||
final serverMessage = await chatRepository.sendMessage(data: data, files: compressedFiles, slug: userSlug);
|
||||
_replaceOptimisticMessage(messageUuid: messageUuid, serverMessage: serverMessage);
|
||||
final serverMessage = await chatRepository.sendMessage(
|
||||
data: data,
|
||||
files: compressedFiles,
|
||||
slug: userSlug,
|
||||
);
|
||||
_replaceOptimisticMessage(
|
||||
messageUuid: messageUuid,
|
||||
serverMessage: serverMessage,
|
||||
);
|
||||
} catch (e, st) {
|
||||
await reportFrom(e, st, this);
|
||||
_emitLoaded(
|
||||
@@ -457,7 +628,11 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
|
||||
_emitLoaded(
|
||||
(current) => current.copyWith(
|
||||
chatMessages: current.chatMessages
|
||||
.map((message) => idSet.contains(message.id) ? message.copyWith(isRead: true) : message)
|
||||
.map(
|
||||
(message) => idSet.contains(message.id)
|
||||
? message.copyWith(isRead: true)
|
||||
: message,
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
);
|
||||
@@ -479,17 +654,26 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
|
||||
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);
|
||||
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: [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,
|
||||
roomId: elem.roomId?.isNotEmpty == true
|
||||
? elem.roomId
|
||||
: serverMessage.roomId,
|
||||
);
|
||||
}
|
||||
return elem;
|
||||
@@ -502,7 +686,10 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
|
||||
try {
|
||||
final planId = (state as ChatRoomStateLoaded).chatRoom.chosenPlan?.id;
|
||||
if (planId == null) return null;
|
||||
final paymentModel = await chatRepository.getPaymentLink(id: planId, paymentProvider: 'alfa-bank');
|
||||
final paymentModel = await chatRepository.getPaymentLink(
|
||||
id: planId,
|
||||
paymentProvider: 'alfa-bank',
|
||||
);
|
||||
return paymentModel.paymentUrl;
|
||||
} catch (e, st) {
|
||||
await reportFrom(e, st, this);
|
||||
@@ -552,24 +739,38 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
|
||||
_timer?.cancel();
|
||||
await messagesSubscription?.cancel();
|
||||
await typingSubscription?.cancel();
|
||||
await _reconnectSubscription?.cancel();
|
||||
super.close();
|
||||
}
|
||||
|
||||
void deleteMessage(String uuid) {
|
||||
final currentState = state;
|
||||
if (currentState is ChatRoomStateLoaded) {
|
||||
emit(currentState.copyWith(chatMessages: currentState.chatMessages.where((message) => message.uuid != uuid).toList()));
|
||||
emit(
|
||||
currentState.copyWith(
|
||||
chatMessages: currentState.chatMessages
|
||||
.where((message) => message.uuid != uuid)
|
||||
.toList(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> resendMessage({required String uuid, required String userSlug}) async {
|
||||
Future<void> resendMessage({
|
||||
required String uuid,
|
||||
required String userSlug,
|
||||
}) async {
|
||||
final currentState = state;
|
||||
if (currentState is ChatRoomStateLoaded) {
|
||||
final message = currentState.chatMessages.firstWhereOrNull((message) => message.uuid == uuid);
|
||||
final message = currentState.chatMessages.firstWhereOrNull(
|
||||
(message) => message.uuid == uuid,
|
||||
);
|
||||
if (message != null) {
|
||||
try {
|
||||
try {
|
||||
unawaited(AudioPlayer().play(AssetSource('audio/send_message.wav')));
|
||||
unawaited(
|
||||
AudioPlayer().play(AssetSource('audio/send_message.wav')),
|
||||
);
|
||||
} catch (_) {}
|
||||
|
||||
List<File>? filesToResend;
|
||||
@@ -601,8 +802,15 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
|
||||
);
|
||||
|
||||
try {
|
||||
final serverMessage = await chatRepository.sendMessage(data: message.data, files: filesToResend, slug: userSlug);
|
||||
_replaceOptimisticMessage(messageUuid: uuid, serverMessage: serverMessage);
|
||||
final serverMessage = await chatRepository.sendMessage(
|
||||
data: message.data,
|
||||
files: filesToResend,
|
||||
slug: userSlug,
|
||||
);
|
||||
_replaceOptimisticMessage(
|
||||
messageUuid: uuid,
|
||||
serverMessage: serverMessage,
|
||||
);
|
||||
} catch (e, st) {
|
||||
await reportFrom(e, st, this);
|
||||
_emitLoaded(
|
||||
|
||||
@@ -5,9 +5,14 @@ import 'dart:developer';
|
||||
import 'package:eatvio_app/core/enums/base_state.dart';
|
||||
import 'package:eatvio_app/core/websockets/ws_manager.dart';
|
||||
import 'package:eatvio_app/features/chat/models/chat_message_model.dart';
|
||||
import 'package:eatvio_app/features/chat/models/chat_room_pagination_model.dart';
|
||||
import 'package:eatvio_app/features/chat/presentation/bloc/chats_cubit/chats_state.dart';
|
||||
import 'package:eatvio_app/features/chat/repositories/chat_repository.dart';
|
||||
import 'package:eatvio_app/features/profile/repositories/profile_repository.dart';
|
||||
import 'package:eatvio_app/features/video_calls/services/video_call_chat_signal.dart';
|
||||
import 'package:eatvio_app/features/video_calls/services/video_call_ended_push_handler.dart';
|
||||
import 'package:eatvio_app/features/video_calls/services/video_call_incoming_push_presenter.dart';
|
||||
import 'package:eatvio_app/features/video_calls/services/video_call_push_payload.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:eatvio_app/core/error_reporting/report_from.dart';
|
||||
|
||||
@@ -17,13 +22,23 @@ class ChatsCubit extends Cubit<ChatsState> {
|
||||
final WsManager wsManager;
|
||||
StreamSubscription? newIncomingMessagesSubscription;
|
||||
StreamSubscription? newLastMessageSubscription;
|
||||
StreamSubscription<void>? reconnectedSubscription;
|
||||
final Set<int> _presentedVideoCallMessageIds = {};
|
||||
|
||||
/// Test seam. Production uses CallKit.
|
||||
Future<void> Function(VideoCallPushPayload payload)? presentIncomingVideoCall;
|
||||
Future<void> Function()? dismissIncomingVideoCall;
|
||||
|
||||
ChatsCubit({
|
||||
required this.chatRepository,
|
||||
required this.wsManager,
|
||||
required this.profileRepository,
|
||||
this.presentIncomingVideoCall,
|
||||
this.dismissIncomingVideoCall,
|
||||
}) : super(ChatsState()) {
|
||||
newLastMessageSubscription = chatRepository.newUserMessagesStream.listen((message) {
|
||||
newLastMessageSubscription = chatRepository.newUserMessagesStream.listen((
|
||||
message,
|
||||
) {
|
||||
_setNewLastMessage(message);
|
||||
});
|
||||
}
|
||||
@@ -34,9 +49,16 @@ class ChatsCubit extends Cubit<ChatsState> {
|
||||
Future<void> connectToWebSocket() async {
|
||||
try {
|
||||
await newIncomingMessagesSubscription?.cancel();
|
||||
newIncomingMessagesSubscription = wsManager.newMessagesStream?.listen((event) {
|
||||
newIncomingMessagesSubscription = wsManager.newMessagesStream?.listen((
|
||||
event,
|
||||
) {
|
||||
_handleNewMessageViaSocket(event.data);
|
||||
});
|
||||
|
||||
await reconnectedSubscription?.cancel();
|
||||
reconnectedSubscription = wsManager.reconnected.listen((_) {
|
||||
unawaited(getChatRooms());
|
||||
});
|
||||
} catch (e, st) {
|
||||
await reportFrom(e, st, this);
|
||||
}
|
||||
@@ -47,7 +69,9 @@ class ChatsCubit extends Cubit<ChatsState> {
|
||||
_query = query;
|
||||
emit(state.copyWith(state: BaseState.loading));
|
||||
try {
|
||||
final chatRoomsPaginationModel = await chatRepository.getChatRooms(query: query);
|
||||
final chatRoomsPaginationModel = await chatRepository.getChatRooms(
|
||||
query: query,
|
||||
);
|
||||
final unreadChats = chatRoomsPaginationModel.unreadChatsCount;
|
||||
lastPage = chatRoomsPaginationModel.meta.lastPage + 1;
|
||||
emit(
|
||||
@@ -60,9 +84,7 @@ class ChatsCubit extends Cubit<ChatsState> {
|
||||
page++;
|
||||
} catch (e, st) {
|
||||
await reportFrom(e, st, this);
|
||||
emit(
|
||||
state.copyWith(errorMessage: e.toString(), state: BaseState.error),
|
||||
);
|
||||
emit(state.copyWith(errorMessage: e.toString(), state: BaseState.error));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,7 +92,9 @@ class ChatsCubit extends Cubit<ChatsState> {
|
||||
page = 1;
|
||||
_query = query;
|
||||
try {
|
||||
final chatRoomsPaginationModel = await chatRepository.getChatRooms(query: query);
|
||||
final chatRoomsPaginationModel = await chatRepository.getChatRooms(
|
||||
query: query,
|
||||
);
|
||||
final unreadChats = chatRoomsPaginationModel.unreadChatsCount;
|
||||
lastPage = chatRoomsPaginationModel.meta.lastPage + 1;
|
||||
emit(
|
||||
@@ -84,9 +108,7 @@ class ChatsCubit extends Cubit<ChatsState> {
|
||||
} catch (e, st) {
|
||||
await reportFrom(e, st, this);
|
||||
log(e.toString(), stackTrace: st);
|
||||
emit(
|
||||
state.copyWith(errorMessage: e.toString(), state: BaseState.error),
|
||||
);
|
||||
emit(state.copyWith(errorMessage: e.toString(), state: BaseState.error));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,7 +116,10 @@ class ChatsCubit extends Cubit<ChatsState> {
|
||||
try {
|
||||
if (state.isLoadingMoreChats || lastPage == page) return;
|
||||
emit(state.copyWith(isLoadingMoreChats: true));
|
||||
final chatRoomsPaginationModel = await chatRepository.getChatRooms(query: _query, page: page);
|
||||
final chatRoomsPaginationModel = await chatRepository.getChatRooms(
|
||||
query: _query,
|
||||
page: page,
|
||||
);
|
||||
final unreadChats = chatRoomsPaginationModel.unreadChatsCount;
|
||||
emit(
|
||||
state.copyWith(
|
||||
@@ -134,7 +159,9 @@ class ChatsCubit extends Cubit<ChatsState> {
|
||||
emit(
|
||||
state.copyWith(
|
||||
chatRooms: updatedChatRooms,
|
||||
chatsWithUnreadMessagesCount: isChatRead ? state.chatsWithUnreadMessagesCount - 1 : state.chatsWithUnreadMessagesCount,
|
||||
chatsWithUnreadMessagesCount: isChatRead
|
||||
? state.chatsWithUnreadMessagesCount - 1
|
||||
: state.chatsWithUnreadMessagesCount,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -144,6 +171,9 @@ class ChatsCubit extends Cubit<ChatsState> {
|
||||
final json = jsonDecode(data) as Map<String, dynamic>;
|
||||
final message = ChatMessageModel.fromJson(json);
|
||||
|
||||
final currentUserId = wsManager.userId;
|
||||
final isOwn = currentUserId != null && message.authorId == currentUserId;
|
||||
|
||||
final currentChatRooms = [...state.chatRooms];
|
||||
|
||||
bool wasChanged = false;
|
||||
@@ -151,10 +181,21 @@ class ChatsCubit extends Cubit<ChatsState> {
|
||||
final updatedChatRooms = currentChatRooms.map((chatRoom) {
|
||||
if (chatRoom.id == message.roomId) {
|
||||
wasChanged = true;
|
||||
if (chatRoom.unreadMessagesCount == 0) {
|
||||
// Идемпотентность: повторная доставка того же message.id (реконнект,
|
||||
// доставка по нескольким каналам) не начисляет непрочитанное дважды.
|
||||
final isDuplicate =
|
||||
message.id != 0 && chatRoom.lastMessage?.id == message.id;
|
||||
// Собственное сообщение (второе устройство автора) обновляет
|
||||
// lastMessage, но не создаёт непрочитанное (S3).
|
||||
if (!isOwn && !isDuplicate && chatRoom.unreadMessagesCount == 0) {
|
||||
willIncrementUnreadChats = true;
|
||||
}
|
||||
return chatRoom.copyWith(lastMessage: message, unreadMessagesCount: chatRoom.unreadMessagesCount + 1);
|
||||
return chatRoom.copyWith(
|
||||
lastMessage: message,
|
||||
unreadMessagesCount: isOwn || isDuplicate
|
||||
? chatRoom.unreadMessagesCount
|
||||
: chatRoom.unreadMessagesCount + 1,
|
||||
);
|
||||
}
|
||||
return chatRoom;
|
||||
}).toList();
|
||||
@@ -165,15 +206,83 @@ class ChatsCubit extends Cubit<ChatsState> {
|
||||
emit(
|
||||
state.copyWith(
|
||||
chatRooms: updatedChatRooms,
|
||||
chatsWithUnreadMessagesCount: willIncrementUnreadChats ? state.chatsWithUnreadMessagesCount + 1 : state.chatsWithUnreadMessagesCount,
|
||||
chatsWithUnreadMessagesCount: willIncrementUnreadChats
|
||||
? state.chatsWithUnreadMessagesCount + 1
|
||||
: state.chatsWithUnreadMessagesCount,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
_presentVideoCallFromMessage(
|
||||
message,
|
||||
isOwn: isOwn,
|
||||
chatRooms: currentChatRooms,
|
||||
);
|
||||
} catch (e, st) {
|
||||
await reportFrom(e, st, this);
|
||||
}
|
||||
}
|
||||
|
||||
void _presentVideoCallFromMessage(
|
||||
ChatMessageModel message, {
|
||||
required bool isOwn,
|
||||
required List<ChatRoom> chatRooms,
|
||||
}) {
|
||||
final kind = VideoCallChatSignal.kindOf(message);
|
||||
if (kind == VideoCallChatSignalKind.ended) {
|
||||
unawaited(_dismissIncomingVideoCallUi());
|
||||
return;
|
||||
}
|
||||
if (isOwn || kind != VideoCallChatSignalKind.started) return;
|
||||
if (message.id != 0 && !_presentedVideoCallMessageIds.add(message.id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
final caller = _callerFromRooms(message, chatRooms);
|
||||
final payload = VideoCallChatSignal.incomingPayload(
|
||||
message: message,
|
||||
callerName: caller?.name,
|
||||
callerAvatarUrl: caller?.profilePhotoUrl,
|
||||
);
|
||||
if (payload == null) return;
|
||||
unawaited(_showIncomingVideoCall(payload));
|
||||
}
|
||||
|
||||
Future<void> _showIncomingVideoCall(VideoCallPushPayload payload) async {
|
||||
try {
|
||||
await (presentIncomingVideoCall ??
|
||||
VideoCallIncomingPushPresenter.showIncomingCall)(payload);
|
||||
} catch (e, st) {
|
||||
log('ChatsCubit: failed to show incoming video call: $e', stackTrace: st);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _dismissIncomingVideoCallUi() async {
|
||||
try {
|
||||
await (dismissIncomingVideoCall ??
|
||||
VideoCallEndedPushHandler.dismissIncomingCallUi)();
|
||||
} catch (e, st) {
|
||||
log(
|
||||
'ChatsCubit: failed to dismiss incoming video call: $e',
|
||||
stackTrace: st,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
ChatUser? _callerFromRooms(
|
||||
ChatMessageModel message,
|
||||
List<ChatRoom> chatRooms,
|
||||
) {
|
||||
for (final room in chatRooms) {
|
||||
if (room.id != message.roomId) continue;
|
||||
final from = room.users.fromChatUser;
|
||||
final to = room.users.toChatUser;
|
||||
if (from.id == message.authorId) return from;
|
||||
if (to.id == message.authorId) return to;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
void _setNewLastMessage(ChatMessageModel message) {
|
||||
final chats = state.chatRooms.map((chat) {
|
||||
if (chat.id == message.roomId) {
|
||||
@@ -188,6 +297,7 @@ class ChatsCubit extends Cubit<ChatsState> {
|
||||
@override
|
||||
Future<void> close() {
|
||||
newIncomingMessagesSubscription?.cancel();
|
||||
reconnectedSubscription?.cancel();
|
||||
return super.close();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,7 +46,12 @@ class ChatScreen extends StatefulWidget {
|
||||
final String? orderId;
|
||||
final bool? sendInitialMessage;
|
||||
|
||||
const ChatScreen({this.uuid, this.orderId, super.key, required this.sendInitialMessage});
|
||||
const ChatScreen({
|
||||
this.uuid,
|
||||
this.orderId,
|
||||
super.key,
|
||||
required this.sendInitialMessage,
|
||||
});
|
||||
|
||||
@override
|
||||
State<ChatScreen> createState() => _ChatScreenState();
|
||||
@@ -57,7 +62,8 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
final TextEditingController textEditingController = TextEditingController();
|
||||
final ChatRoomCubit chatRoomCubit = sl<ChatRoomCubit>();
|
||||
final List<int> unreadMessageIds = [];
|
||||
final showableChatBadgeStates = ChosenPlanStateEnum.getShowableChatBadgeStates();
|
||||
final showableChatBadgeStates =
|
||||
ChosenPlanStateEnum.getShowableChatBadgeStates();
|
||||
late AppLifecycleListener _listener;
|
||||
List<File> pickedFiles = [];
|
||||
|
||||
@@ -108,7 +114,8 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
void _onScroll() {
|
||||
final uuid = conversationUuid;
|
||||
if (uuid == null) return;
|
||||
if (scrollController.offset >= scrollController.position.maxScrollExtent && !scrollController.position.outOfRange) {
|
||||
if (scrollController.offset >= scrollController.position.maxScrollExtent &&
|
||||
!scrollController.position.outOfRange) {
|
||||
chatRoomCubit.loadNextPage(uuid);
|
||||
}
|
||||
}
|
||||
@@ -168,9 +175,13 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
}
|
||||
|
||||
Scaffold _buildChat(BuildContext context, ChatRoomStateLoaded state) {
|
||||
final chatCounterpart = resolveChatCounterpart(users: state.chatRoom.users, currentUserId: sl<WsManager>().userId);
|
||||
final chatCounterpart = resolveChatCounterpart(
|
||||
users: state.chatRoom.users,
|
||||
currentUserId: sl<WsManager>().userId,
|
||||
);
|
||||
final chosenPlan = state.chatRoom.chosenPlan;
|
||||
final amIConsultant = chosenPlan?.consultantId == state.chatRoom.users.sender.id;
|
||||
final amIConsultant =
|
||||
chosenPlan?.consultantId == state.chatRoom.users.sender.id;
|
||||
final showUserDetailsButton = [
|
||||
ChosenPlanStateEnum.stateStartDateHasBeenSet,
|
||||
ChosenPlanStateEnum.statePendingPayment,
|
||||
@@ -186,22 +197,37 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
}
|
||||
|
||||
final showAboutOrderButton =
|
||||
state.chatRoom.chosenPlan != null && state.chatRoom.chosenPlan!.chosenPlanState != ChosenPlanStateEnum.stateCanceledByTheInitiator;
|
||||
state.chatRoom.chosenPlan != null &&
|
||||
state.chatRoom.chosenPlan!.chosenPlanState !=
|
||||
ChosenPlanStateEnum.stateCanceledByTheInitiator;
|
||||
BannerType? bannerType;
|
||||
if (chosenPlan == null || chosenPlan.chosenPlanState == ChosenPlanStateEnum.stateCanceledByTheInitiator) {
|
||||
if (chosenPlan == null ||
|
||||
chosenPlan.chosenPlanState ==
|
||||
ChosenPlanStateEnum.stateCanceledByTheInitiator) {
|
||||
bannerType = null;
|
||||
} else if (amIConsultant && !state.hasCard && chosenPlan.chosenPlanState == ChosenPlanStateEnum.stateInCoordination) {
|
||||
} else if (amIConsultant &&
|
||||
!state.hasCard &&
|
||||
chosenPlan.chosenPlanState == ChosenPlanStateEnum.stateInCoordination) {
|
||||
bannerType = BannerType.card;
|
||||
} else if (amIConsultant && chosenPlan.chosenPlanState == ChosenPlanStateEnum.stateInCoordination) {
|
||||
} else if (amIConsultant &&
|
||||
chosenPlan.chosenPlanState == ChosenPlanStateEnum.stateInCoordination) {
|
||||
bannerType = BannerType.date;
|
||||
} else if (!amIConsultant &&
|
||||
(chosenPlan.chosenPlanState == ChosenPlanStateEnum.stateStartDateHasBeenSet || chosenPlan.chosenPlanState == ChosenPlanStateEnum.statePendingPayment)) {
|
||||
(chosenPlan.chosenPlanState ==
|
||||
ChosenPlanStateEnum.stateStartDateHasBeenSet ||
|
||||
chosenPlan.chosenPlanState ==
|
||||
ChosenPlanStateEnum.statePendingPayment)) {
|
||||
bannerType = BannerType.payment;
|
||||
} else if (chosenPlan.chosenPlanState == ChosenPlanStateEnum.stateDone && amIConsultant) {
|
||||
} else if (chosenPlan.chosenPlanState == ChosenPlanStateEnum.stateDone &&
|
||||
amIConsultant) {
|
||||
bannerType = BannerType.collectCharge;
|
||||
} else if (chosenPlan.chosenPlanState == ChosenPlanStateEnum.stateLastDayFinished && !amIConsultant) {
|
||||
} else if (chosenPlan.chosenPlanState ==
|
||||
ChosenPlanStateEnum.stateLastDayFinished &&
|
||||
!amIConsultant) {
|
||||
bannerType = BannerType.finishPlan;
|
||||
} else if (chosenOption?.hasPersonalDiet == true && amIConsultant && chosenPlan.chosenPlanState != ChosenPlanStateEnum.stateDone) {
|
||||
} else if (chosenOption?.hasPersonalDiet == true &&
|
||||
amIConsultant &&
|
||||
chosenPlan.chosenPlanState != ChosenPlanStateEnum.stateDone) {
|
||||
bannerType = BannerType.setPersonalDiet;
|
||||
}
|
||||
|
||||
@@ -223,25 +249,40 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
|
||||
title: GestureDetector(
|
||||
onTap: () {
|
||||
GoRouter.of(context).pushNamed('profileScreen', extra: state.chatRoom.users.receiver.slug);
|
||||
GoRouter.of(context).pushNamed(
|
||||
'profileScreen',
|
||||
extra: state.chatRoom.users.receiver.slug,
|
||||
);
|
||||
},
|
||||
child: Row(
|
||||
children: [
|
||||
if (state.chatRoom.users.receiver.profilePhotoUrl != null)
|
||||
CircleAvatar(
|
||||
radius: 20,
|
||||
foregroundImage: NetworkImage(state.chatRoom.users.receiver.profilePhotoUrl!),
|
||||
backgroundImage: const AssetImage('assets/images/no_avatar.png'),
|
||||
foregroundImage: NetworkImage(
|
||||
state.chatRoom.users.receiver.profilePhotoUrl!,
|
||||
),
|
||||
backgroundImage: const AssetImage(
|
||||
'assets/images/no_avatar.png',
|
||||
),
|
||||
),
|
||||
if (state.chatRoom.users.receiver.profilePhotoUrl == null)
|
||||
Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
alignment: Alignment.center,
|
||||
decoration: const BoxDecoration(color: Color(0xFFEBF4FF), shape: BoxShape.circle),
|
||||
decoration: const BoxDecoration(
|
||||
color: Color(0xFFEBF4FF),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Text(
|
||||
state.chatRoom.users.receiver.name.split(' ').first[0] + state.chatRoom.users.receiver.name.split(' ')[1][0],
|
||||
style: const TextStyle(color: Color(0xFF7F9CF5), fontWeight: FontWeight.w400, fontSize: 20),
|
||||
state.chatRoom.users.receiver.name.split(' ').first[0] +
|
||||
state.chatRoom.users.receiver.name.split(' ')[1][0],
|
||||
style: const TextStyle(
|
||||
color: Color(0xFF7F9CF5),
|
||||
fontWeight: FontWeight.w400,
|
||||
fontSize: 20,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
@@ -253,10 +294,17 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
state.chatRoom.users.receiver.name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: CustomTextStyles.titleSmallBold.copyWith(color: PrimaryColors().black),
|
||||
style: CustomTextStyles.titleSmallBold.copyWith(
|
||||
color: PrimaryColors().black,
|
||||
),
|
||||
),
|
||||
AnimatedCrossFade(
|
||||
firstChild: Text('Печатает...', maxLines: 1, overflow: TextOverflow.ellipsis, style: CustomTextStyles.bodySmallMulishPrimary),
|
||||
firstChild: Text(
|
||||
'Печатает...',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: CustomTextStyles.bodySmallMulishPrimary,
|
||||
),
|
||||
firstCurve: Curves.easeIn,
|
||||
secondCurve: Curves.easeOut,
|
||||
secondChild: ChatUserOnlineStatusLine(
|
||||
@@ -265,7 +313,9 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
style: CustomTextStyles.bodySmallMulishPrimary,
|
||||
showOnlineDot: false,
|
||||
),
|
||||
crossFadeState: state.isTyping ? CrossFadeState.showFirst : CrossFadeState.showSecond,
|
||||
crossFadeState: state.isTyping
|
||||
? CrossFadeState.showFirst
|
||||
: CrossFadeState.showSecond,
|
||||
duration: const Duration(milliseconds: 150),
|
||||
),
|
||||
],
|
||||
@@ -277,7 +327,9 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
actions: [
|
||||
Padding(
|
||||
padding: const EdgeInsetsDirectional.only(end: 8),
|
||||
child: _ChatHeaderVideoCallButton(onPressed: () => _openVideoCall(context, state, chatCounterpart)),
|
||||
child: _ChatHeaderVideoCallButton(
|
||||
onPressed: () => _openVideoCall(context, state, chatCounterpart),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -291,44 +343,133 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
child: Container(
|
||||
color: appTheme.whiteA700,
|
||||
width: double.maxFinite,
|
||||
child: SingleChildScrollView(
|
||||
physics: const BouncingScrollPhysics(),
|
||||
controller: scrollController,
|
||||
reverse: true,
|
||||
child: Column(
|
||||
children: [
|
||||
const SizedBox(height: 16),
|
||||
ListView.builder(
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
reverse: true,
|
||||
shrinkWrap: true,
|
||||
itemBuilder: (context, index) {
|
||||
if (index == state.chatMessages.length) {
|
||||
return const Padding(padding: EdgeInsets.all(8), child: Loader());
|
||||
}
|
||||
child: RefreshIndicator(
|
||||
onRefresh: chatRoomCubit.refreshChat,
|
||||
child: SingleChildScrollView(
|
||||
physics: const AlwaysScrollableScrollPhysics(
|
||||
parent: BouncingScrollPhysics(),
|
||||
),
|
||||
controller: scrollController,
|
||||
reverse: true,
|
||||
child: Column(
|
||||
children: [
|
||||
const SizedBox(height: 16),
|
||||
ListView.builder(
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
reverse: true,
|
||||
shrinkWrap: true,
|
||||
itemBuilder: (context, index) {
|
||||
if (index == state.chatMessages.length) {
|
||||
return const Padding(
|
||||
padding: EdgeInsets.all(8),
|
||||
child: Loader(),
|
||||
);
|
||||
}
|
||||
|
||||
if ((index + 1 != state.chatMessages.length &&
|
||||
state.chatMessages[index].updatedAt.day != state.chatMessages[index + 1].updatedAt.day) ||
|
||||
index + 1 == state.chatMessages.length) {
|
||||
final isCurrentUserAuthor = state.chatRoom.users.receiver.id != state.chatMessages[index].authorId;
|
||||
if ((index + 1 != state.chatMessages.length &&
|
||||
state
|
||||
.chatMessages[index]
|
||||
.updatedAt
|
||||
.day !=
|
||||
state
|
||||
.chatMessages[index + 1]
|
||||
.updatedAt
|
||||
.day) ||
|
||||
index + 1 == state.chatMessages.length) {
|
||||
final isCurrentUserAuthor =
|
||||
state.chatRoom.users.receiver.id !=
|
||||
state.chatMessages[index].authorId;
|
||||
final message = state.chatMessages[index];
|
||||
final isPlanOrderMessage =
|
||||
message.type == ChatMessageType.planOrder;
|
||||
|
||||
PacketOptions? messageOption;
|
||||
if (isPlanOrderMessage &&
|
||||
chosenPlan != null) {
|
||||
if (chosenPlan.optionType == 'options1') {
|
||||
messageOption =
|
||||
chosenPlan.plan.packet1Options;
|
||||
} else if (chosenPlan.optionType ==
|
||||
'options2') {
|
||||
messageOption =
|
||||
chosenPlan.plan.packet2Options;
|
||||
} else if (chosenPlan.optionType ==
|
||||
'options3') {
|
||||
messageOption =
|
||||
chosenPlan.plan.packet3Options;
|
||||
}
|
||||
}
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
_messageDate(message.updatedAt),
|
||||
if (isPlanOrderMessage &&
|
||||
chosenPlan != null &&
|
||||
messageOption != null)
|
||||
PlanOrderMessageWidget(
|
||||
planName: chosenPlan.plan.name,
|
||||
planSlug: chosenPlan.plan.slug,
|
||||
price: messageOption.price,
|
||||
sendTime: message.updatedAt,
|
||||
isCurrentUser: isCurrentUserAuthor,
|
||||
),
|
||||
VisibilityDetector(
|
||||
key: ValueKey(
|
||||
'msg-${message.uuid ?? message.id}',
|
||||
),
|
||||
onVisibilityChanged:
|
||||
(VisibilityInfo info) {
|
||||
_queueMarkAsRead(
|
||||
context: context,
|
||||
chatId: state.chatRoom.id,
|
||||
message: message,
|
||||
isCurrentUserAuthor:
|
||||
isCurrentUserAuthor,
|
||||
visibleFraction:
|
||||
info.visibleFraction,
|
||||
);
|
||||
},
|
||||
child: _buildMessageBubble(
|
||||
context: context,
|
||||
state: state,
|
||||
chatCounterpart: chatCounterpart,
|
||||
isCurrentUserAuthor:
|
||||
isCurrentUserAuthor,
|
||||
message: message,
|
||||
amIConsultant: amIConsultant,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
final isCurrentUserAuthor =
|
||||
state.chatRoom.users.receiver.id !=
|
||||
state.chatMessages[index].authorId;
|
||||
final message = state.chatMessages[index];
|
||||
final isPlanOrderMessage = message.type == ChatMessageType.planOrder;
|
||||
final isPlanOrderMessage =
|
||||
message.type == ChatMessageType.planOrder;
|
||||
|
||||
PacketOptions? messageOption;
|
||||
if (isPlanOrderMessage && chosenPlan != null) {
|
||||
if (chosenPlan.optionType == 'options1') {
|
||||
messageOption = chosenPlan.plan.packet1Options;
|
||||
} else if (chosenPlan.optionType == 'options2') {
|
||||
messageOption = chosenPlan.plan.packet2Options;
|
||||
} else if (chosenPlan.optionType == 'options3') {
|
||||
messageOption = chosenPlan.plan.packet3Options;
|
||||
messageOption =
|
||||
chosenPlan.plan.packet1Options;
|
||||
} else if (chosenPlan.optionType ==
|
||||
'options2') {
|
||||
messageOption =
|
||||
chosenPlan.plan.packet2Options;
|
||||
} else if (chosenPlan.optionType ==
|
||||
'options3') {
|
||||
messageOption =
|
||||
chosenPlan.plan.packet3Options;
|
||||
}
|
||||
}
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
_messageDate(message.updatedAt),
|
||||
if (isPlanOrderMessage && chosenPlan != null && messageOption != null)
|
||||
if (isPlanOrderMessage &&
|
||||
chosenPlan != null &&
|
||||
messageOption != null)
|
||||
PlanOrderMessageWidget(
|
||||
planName: chosenPlan.plan.name,
|
||||
planSlug: chosenPlan.plan.slug,
|
||||
@@ -337,117 +478,94 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
isCurrentUser: isCurrentUserAuthor,
|
||||
),
|
||||
VisibilityDetector(
|
||||
key: ValueKey('msg-${message.uuid ?? message.id}'),
|
||||
onVisibilityChanged: (VisibilityInfo info) {
|
||||
_queueMarkAsRead(
|
||||
context: context,
|
||||
chatId: state.chatRoom.id,
|
||||
message: message,
|
||||
isCurrentUserAuthor: isCurrentUserAuthor,
|
||||
visibleFraction: info.visibleFraction,
|
||||
);
|
||||
},
|
||||
key: ValueKey(
|
||||
'msg-${message.uuid ?? message.id}',
|
||||
),
|
||||
onVisibilityChanged:
|
||||
(VisibilityInfo info) {
|
||||
_queueMarkAsRead(
|
||||
context: context,
|
||||
chatId: state.chatRoom.id,
|
||||
message: message,
|
||||
isCurrentUserAuthor:
|
||||
isCurrentUserAuthor,
|
||||
visibleFraction:
|
||||
info.visibleFraction,
|
||||
);
|
||||
},
|
||||
child: _buildMessageBubble(
|
||||
context: context,
|
||||
state: state,
|
||||
chatCounterpart: chatCounterpart,
|
||||
isCurrentUserAuthor: isCurrentUserAuthor,
|
||||
isCurrentUserAuthor:
|
||||
isCurrentUserAuthor,
|
||||
message: message,
|
||||
amIConsultant: amIConsultant,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
final isCurrentUserAuthor = state.chatRoom.users.receiver.id != state.chatMessages[index].authorId;
|
||||
final message = state.chatMessages[index];
|
||||
final isPlanOrderMessage = message.type == ChatMessageType.planOrder;
|
||||
|
||||
PacketOptions? messageOption;
|
||||
if (isPlanOrderMessage && chosenPlan != null) {
|
||||
if (chosenPlan.optionType == 'options1') {
|
||||
messageOption = chosenPlan.plan.packet1Options;
|
||||
} else if (chosenPlan.optionType == 'options2') {
|
||||
messageOption = chosenPlan.plan.packet2Options;
|
||||
} else if (chosenPlan.optionType == 'options3') {
|
||||
messageOption = chosenPlan.plan.packet3Options;
|
||||
}
|
||||
}
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
if (isPlanOrderMessage && chosenPlan != null && messageOption != null)
|
||||
PlanOrderMessageWidget(
|
||||
planName: chosenPlan.plan.name,
|
||||
planSlug: chosenPlan.plan.slug,
|
||||
price: messageOption.price,
|
||||
sendTime: message.updatedAt,
|
||||
isCurrentUser: isCurrentUserAuthor,
|
||||
},
|
||||
itemCount:
|
||||
state.chatMessages.length +
|
||||
(state.isLoading ? 1 : 0),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
if (showUserDetailsButton) ...[
|
||||
// OrderCard(
|
||||
// plan: chosenPlan.plan,
|
||||
// isCustomerView: !amIConsultant,
|
||||
// optionType: chosenPlan.optionType,
|
||||
// onCancel: () async {
|
||||
// await context.read<ChatRoomCubit>().cancelChosenPlan(chatId: state.chatRoom.id, planId: chosenPlan.id);
|
||||
// },
|
||||
// ),
|
||||
if (amIConsultant)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical: 12,
|
||||
horizontal: 16,
|
||||
),
|
||||
child: CustomOutlinedButton(
|
||||
text: S.current.user_info,
|
||||
height: 52,
|
||||
buttonTextStyle: TextStyles.textMSemibold
|
||||
.copyWith(color: appTheme.whiteA700),
|
||||
buttonStyle: ButtonStyle(
|
||||
side: const WidgetStatePropertyAll(
|
||||
BorderSide.none,
|
||||
),
|
||||
backgroundColor: WidgetStatePropertyAll(
|
||||
appTheme.blueA400,
|
||||
),
|
||||
),
|
||||
VisibilityDetector(
|
||||
key: ValueKey('msg-${message.uuid ?? message.id}'),
|
||||
onVisibilityChanged: (VisibilityInfo info) {
|
||||
_queueMarkAsRead(
|
||||
context: context,
|
||||
chatId: state.chatRoom.id,
|
||||
message: message,
|
||||
isCurrentUserAuthor: isCurrentUserAuthor,
|
||||
visibleFraction: info.visibleFraction,
|
||||
onPressed: () async {
|
||||
GoRouter.of(context).push(
|
||||
UserDetailsScreen.path,
|
||||
extra:
|
||||
state.chatRoom.users.receiver.slug,
|
||||
);
|
||||
},
|
||||
child: _buildMessageBubble(
|
||||
context: context,
|
||||
state: state,
|
||||
chatCounterpart: chatCounterpart,
|
||||
isCurrentUserAuthor: isCurrentUserAuthor,
|
||||
message: message,
|
||||
amIConsultant: amIConsultant,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
itemCount: state.chatMessages.length + (state.isLoading ? 1 : 0),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
if (showUserDetailsButton) ...[
|
||||
// OrderCard(
|
||||
// plan: chosenPlan.plan,
|
||||
// isCustomerView: !amIConsultant,
|
||||
// optionType: chosenPlan.optionType,
|
||||
// onCancel: () async {
|
||||
// await context.read<ChatRoomCubit>().cancelChosenPlan(chatId: state.chatRoom.id, planId: chosenPlan.id);
|
||||
// },
|
||||
// ),
|
||||
if (amIConsultant)
|
||||
),
|
||||
],
|
||||
if (bannerType != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16),
|
||||
child: CustomOutlinedButton(
|
||||
text: S.current.user_info,
|
||||
height: 52,
|
||||
buttonTextStyle: TextStyles.textMSemibold.copyWith(color: appTheme.whiteA700),
|
||||
buttonStyle: ButtonStyle(
|
||||
side: const WidgetStatePropertyAll(BorderSide.none),
|
||||
backgroundColor: WidgetStatePropertyAll(appTheme.blueA400),
|
||||
),
|
||||
onPressed: () async {
|
||||
GoRouter.of(context).push(UserDetailsScreen.path, extra: state.chatRoom.users.receiver.slug);
|
||||
},
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical: 12,
|
||||
horizontal: 16,
|
||||
),
|
||||
child: NotificationBanner(
|
||||
bannerType: bannerType,
|
||||
chosenPlanId: chosenPlan?.id,
|
||||
planName: chosenPlan?.plan.name,
|
||||
planPrice: chosenOption?.price.toDouble(),
|
||||
hasPersonalDiet:
|
||||
chosenOption?.hasPersonalDiet ?? false,
|
||||
),
|
||||
),
|
||||
],
|
||||
if (bannerType != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16),
|
||||
child: NotificationBanner(
|
||||
bannerType: bannerType,
|
||||
chosenPlanId: chosenPlan?.id,
|
||||
planName: chosenPlan?.plan.name,
|
||||
planPrice: chosenOption?.price.toDouble(),
|
||||
hasPersonalDiet: chosenOption?.hasPersonalDiet ?? false,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -472,19 +590,40 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
_isImageFile(pickedFiles[i].path)
|
||||
? GestureDetector(
|
||||
onTap: () {
|
||||
final imageFiles = pickedFiles.where((f) => _isImageFile(f.path)).toList();
|
||||
final imageIndex = _imageIndexAt(pickedFiles, i);
|
||||
final imageFiles = pickedFiles
|
||||
.where(
|
||||
(f) => _isImageFile(f.path),
|
||||
)
|
||||
.toList();
|
||||
final imageIndex = _imageIndexAt(
|
||||
pickedFiles,
|
||||
i,
|
||||
);
|
||||
|
||||
context.pushTransparentRoute(
|
||||
PhotoViewScreen(imageUrls: imageFiles.map((e) => e.path).toList(), isLocal: true, index: imageIndex),
|
||||
PhotoViewScreen(
|
||||
imageUrls: imageFiles
|
||||
.map((e) => e.path)
|
||||
.toList(),
|
||||
isLocal: true,
|
||||
index: imageIndex,
|
||||
),
|
||||
);
|
||||
},
|
||||
child: Hero(
|
||||
tag: pickedFiles[i].path,
|
||||
transitionOnUserGestures: true,
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: SizedBox(height: 104, width: 104, child: Image.file(pickedFiles[i], fit: BoxFit.cover)),
|
||||
borderRadius:
|
||||
BorderRadius.circular(8),
|
||||
child: SizedBox(
|
||||
height: 104,
|
||||
width: 104,
|
||||
child: Image.file(
|
||||
pickedFiles[i],
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
@@ -501,8 +640,15 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
},
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(4),
|
||||
decoration: BoxDecoration(color: appTheme.gray50, shape: BoxShape.circle),
|
||||
child: const Icon(size: 20, Icons.close, color: Colors.black),
|
||||
decoration: BoxDecoration(
|
||||
color: appTheme.gray50,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(
|
||||
size: 20,
|
||||
Icons.close,
|
||||
color: Colors.black,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -519,14 +665,20 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
children: [
|
||||
const SizedBox(width: 8),
|
||||
InkWell(
|
||||
onTap: () => showImagePicker(state.chatRoom.users.receiver.slug),
|
||||
onTap: () => showImagePicker(
|
||||
state.chatRoom.users.receiver.slug,
|
||||
),
|
||||
customBorder: const CircleBorder(),
|
||||
child: Ink(
|
||||
height: 40,
|
||||
width: 40,
|
||||
child: Transform.rotate(
|
||||
angle: pi / 4,
|
||||
child: const Icon(Icons.attach_file_rounded, size: 30, color: Color(0xFF858E99)),
|
||||
child: const Icon(
|
||||
Icons.attach_file_rounded,
|
||||
size: 30,
|
||||
color: Color(0xFF858E99),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -538,24 +690,46 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
maxLines: 3,
|
||||
minLines: 1,
|
||||
keyboardType: TextInputType.multiline,
|
||||
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w400, color: Colors.black, height: 1),
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w400,
|
||||
color: Colors.black,
|
||||
height: 1,
|
||||
),
|
||||
decoration: InputDecoration(
|
||||
isDense: true,
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 10,
|
||||
),
|
||||
hintText: 'Сообщение',
|
||||
hintStyle: const TextStyle(fontSize: 14, fontWeight: FontWeight.w400, height: 1, color: Color(0xFFAEAEB2)),
|
||||
hintStyle: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w400,
|
||||
height: 1,
|
||||
color: Color(0xFFAEAEB2),
|
||||
),
|
||||
filled: true,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(100),
|
||||
borderSide: const BorderSide(color: Color(0xFFD1D1D6), width: 1),
|
||||
borderSide: const BorderSide(
|
||||
color: Color(0xFFD1D1D6),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(100),
|
||||
borderSide: const BorderSide(color: Color(0xFFD1D1D6), width: 1),
|
||||
borderSide: const BorderSide(
|
||||
color: Color(0xFFD1D1D6),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(100),
|
||||
borderSide: const BorderSide(color: Color(0xFFD1D1D6), width: 1),
|
||||
borderSide: const BorderSide(
|
||||
color: Color(0xFFD1D1D6),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -570,12 +744,18 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
final blockedState = blockedUsersCubit.state;
|
||||
|
||||
if (blockedState is BlockedUsersStateLoaded) {
|
||||
final isBlocked = blockedState.profilesList.any((profile) => profile.slug == state.chatRoom.users.receiver.slug);
|
||||
final isBlocked = blockedState.profilesList.any(
|
||||
(profile) =>
|
||||
profile.slug ==
|
||||
state.chatRoom.users.receiver.slug,
|
||||
);
|
||||
if (isBlocked) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Вы не можете отправить сообщение заблокированному пользователю'),
|
||||
content: Text(
|
||||
'Вы не можете отправить сообщение заблокированному пользователю',
|
||||
),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
@@ -584,10 +764,15 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
if (textEditingController.text.isNotEmpty || pickedFiles.isNotEmpty) {
|
||||
if (textEditingController.text.isNotEmpty ||
|
||||
pickedFiles.isNotEmpty) {
|
||||
chatRoomCubit.sendMessage(
|
||||
data: textEditingController.text.isEmpty ? null : textEditingController.text.trim(),
|
||||
files: pickedFiles.isEmpty ? null : pickedFiles,
|
||||
data: textEditingController.text.isEmpty
|
||||
? null
|
||||
: textEditingController.text.trim(),
|
||||
files: pickedFiles.isEmpty
|
||||
? null
|
||||
: pickedFiles,
|
||||
userSlug: state.chatRoom.users.receiver.slug,
|
||||
);
|
||||
setState(() {
|
||||
@@ -597,10 +782,17 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
}
|
||||
},
|
||||
child: Ink(
|
||||
decoration: BoxDecoration(color: appTheme.blueA400, shape: BoxShape.circle),
|
||||
decoration: BoxDecoration(
|
||||
color: appTheme.blueA400,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
height: 40,
|
||||
width: 40,
|
||||
child: const Icon(Icons.arrow_upward_rounded, size: 24, color: Colors.white),
|
||||
child: const Icon(
|
||||
Icons.arrow_upward_rounded,
|
||||
size: 24,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
@@ -619,12 +811,19 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
child: AboutOrderButton(
|
||||
onTap: () async {
|
||||
if (amIConsultant) {
|
||||
GoRouter.of(
|
||||
context,
|
||||
).push(InfoByOrderScreen.path, extra: {'planId': state.chatRoom.chosenPlan?.id, 'customerSlug': state.chatRoom.users.receiver.slug});
|
||||
GoRouter.of(context).push(
|
||||
InfoByOrderScreen.path,
|
||||
extra: {
|
||||
'planId': state.chatRoom.chosenPlan?.id,
|
||||
'customerSlug': state.chatRoom.users.receiver.slug,
|
||||
},
|
||||
);
|
||||
return;
|
||||
}
|
||||
GoRouter.of(context).push(InfoByOrderScreen.path, extra: {'planId': state.chatRoom.chosenPlan?.id});
|
||||
GoRouter.of(context).push(
|
||||
InfoByOrderScreen.path,
|
||||
extra: {'planId': state.chatRoom.chosenPlan?.id},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
@@ -649,7 +848,13 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
const Icon(Icons.attach_file, size: 32, color: Colors.black54),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||
child: Text(fileName, textAlign: TextAlign.center, maxLines: 2, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 12)),
|
||||
child: Text(
|
||||
fileName,
|
||||
textAlign: TextAlign.center,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(fontSize: 12),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -697,7 +902,13 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
// await chatRoomCubit.sendMessage(files: [File(image.path)], userSlug: slug);
|
||||
// }
|
||||
},
|
||||
child: const Row(children: [Icon(Icons.photo_camera), SizedBox(width: 8), Text('Фото')]),
|
||||
child: const Row(
|
||||
children: [
|
||||
Icon(Icons.photo_camera),
|
||||
SizedBox(width: 8),
|
||||
Text('Фото'),
|
||||
],
|
||||
),
|
||||
),
|
||||
CupertinoActionSheetAction(
|
||||
onPressed: () async {
|
||||
@@ -706,13 +917,21 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
List<XFile> images = await picker.pickMultiImage();
|
||||
if (images != []) {
|
||||
setState(() {
|
||||
pickedFiles.addAll(images.map((image) => File(image.path)).toList());
|
||||
pickedFiles.addAll(
|
||||
images.map((image) => File(image.path)).toList(),
|
||||
);
|
||||
});
|
||||
// List<File> files = images.map((image) => File(image.path)).toList();
|
||||
// await chatRoomCubit.sendMessage(files: files, userSlug: slug);
|
||||
}
|
||||
},
|
||||
child: const Row(children: [Icon(Icons.photo_library_rounded), SizedBox(width: 8), Text('Галерея')]),
|
||||
child: const Row(
|
||||
children: [
|
||||
Icon(Icons.photo_library_rounded),
|
||||
SizedBox(width: 8),
|
||||
Text('Галерея'),
|
||||
],
|
||||
),
|
||||
),
|
||||
CupertinoActionSheetAction(
|
||||
onPressed: () async {
|
||||
@@ -744,15 +963,27 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
if (result != null) {
|
||||
// List<File> files = result.paths.map((path) => File(path!)).toList();
|
||||
setState(() {
|
||||
pickedFiles.addAll(result.paths.map((path) => File(path!)).toList());
|
||||
pickedFiles.addAll(
|
||||
result.paths.map((path) => File(path!)).toList(),
|
||||
);
|
||||
});
|
||||
// await chatRoomCubit.sendMessage(files: files, userSlug: slug);
|
||||
}
|
||||
},
|
||||
child: const Row(children: [Icon(Icons.file_present_rounded), SizedBox(width: 8), Text('Файлы')]),
|
||||
child: const Row(
|
||||
children: [
|
||||
Icon(Icons.file_present_rounded),
|
||||
SizedBox(width: 8),
|
||||
Text('Файлы'),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
cancelButton: CupertinoActionSheetAction(onPressed: () => Navigator.of(context).pop(), isDestructiveAction: true, child: const Text('Отмена')),
|
||||
cancelButton: CupertinoActionSheetAction(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
isDestructiveAction: true,
|
||||
child: const Text('Отмена'),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -763,13 +994,21 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
padding: const EdgeInsets.only(top: 32),
|
||||
child: Text(
|
||||
DateFormat.yMMMMd('RU').format(date),
|
||||
style: const TextStyle(height: 1, color: Color(0xFF8393A1), fontWeight: FontWeight.w500),
|
||||
style: const TextStyle(
|
||||
height: 1,
|
||||
color: Color(0xFF8393A1),
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _openVideoCall(BuildContext context, ChatRoomStateLoaded state, ChatUserModel chatCounterpart) {
|
||||
void _openVideoCall(
|
||||
BuildContext context,
|
||||
ChatRoomStateLoaded state,
|
||||
ChatUserModel chatCounterpart,
|
||||
) {
|
||||
GoRouter.of(context).pushNamed(
|
||||
VideoCallScreen.name,
|
||||
extra: <String, dynamic>{
|
||||
@@ -797,18 +1036,28 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
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);
|
||||
});
|
||||
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({
|
||||
@@ -819,11 +1068,13 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
required ChatMessageModel message,
|
||||
required bool? amIConsultant,
|
||||
}) {
|
||||
if (message.type == ChatMessageType.videoCallStarted && !isCurrentUserAuthor) {
|
||||
if (message.type == ChatMessageType.videoCallStarted &&
|
||||
!isCurrentUserAuthor) {
|
||||
return VideoCallChatMessageWidget(
|
||||
text: message.data.trim(),
|
||||
sendTime: message.updatedAt,
|
||||
onVideoCallPressed: () => _openVideoCall(context, state, chatCounterpart),
|
||||
onVideoCallPressed: () =>
|
||||
_openVideoCall(context, state, chatCounterpart),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -862,7 +1113,13 @@ class _ChatHeaderVideoCallButton extends StatelessWidget {
|
||||
child: SizedBox(
|
||||
width: 40,
|
||||
height: 40,
|
||||
child: Center(child: Icon(Icons.videocam_rounded, color: appTheme.blueA400, size: 18)),
|
||||
child: Center(
|
||||
child: Icon(
|
||||
Icons.videocam_rounded,
|
||||
color: appTheme.blueA400,
|
||||
size: 18,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -27,6 +27,12 @@ class AddItemWidget extends StatefulWidget {
|
||||
class _AddItemWidgetState extends State<AddItemWidget> {
|
||||
final controller = TextEditingController();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
@@ -38,14 +44,24 @@ class _AddItemWidgetState extends State<AddItemWidget> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final s = S.of(context);
|
||||
if (widget.items.length > widget.index && controller.text != widget.items[widget.index]) {
|
||||
if (widget.items.length > widget.index &&
|
||||
controller.text != widget.items[widget.index]) {
|
||||
controller.text = widget.items[widget.index];
|
||||
}
|
||||
return BlocBuilder<CreatePlanCubit, CreatePlanState>(
|
||||
buildWhen: (previous, current) => previous.validationState != current.validationState,
|
||||
buildWhen: (previous, current) =>
|
||||
previous.validationState != current.validationState,
|
||||
builder: (context, state) {
|
||||
final hasError = state.validationState.optionsValidationState[state.selectedOptionIndex ?? 0].isItemsEmpty ||
|
||||
(state.validationState.optionsValidationState[state.selectedOptionIndex ?? 0].itemsErrors[widget.index] ?? false);
|
||||
final hasError =
|
||||
state
|
||||
.validationState
|
||||
.optionsValidationState[state.selectedOptionIndex ?? 0]
|
||||
.isItemsEmpty ||
|
||||
(state
|
||||
.validationState
|
||||
.optionsValidationState[state.selectedOptionIndex ?? 0]
|
||||
.itemsErrors[widget.index] ??
|
||||
false);
|
||||
return Column(
|
||||
children: [
|
||||
Row(
|
||||
@@ -54,9 +70,14 @@ class _AddItemWidgetState extends State<AddItemWidget> {
|
||||
child: CustomTextFormField(
|
||||
controller: controller,
|
||||
hasError: hasError,
|
||||
hintText: widget.enableHint ? s.option_content_hint_text : null,
|
||||
hintText: widget.enableHint
|
||||
? s.option_content_hint_text
|
||||
: null,
|
||||
onChanged: (value) {
|
||||
context.read<CreatePlanCubit>().setOptionContent(value, widget.index);
|
||||
context.read<CreatePlanCubit>().setOptionContent(
|
||||
value,
|
||||
widget.index,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
@@ -64,7 +85,9 @@ class _AddItemWidgetState extends State<AddItemWidget> {
|
||||
const SizedBox(width: 8),
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
context.read<CreatePlanCubit>().removeOptionContentItem(widget.index);
|
||||
context.read<CreatePlanCubit>().removeOptionContentItem(
|
||||
widget.index,
|
||||
);
|
||||
},
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(14),
|
||||
@@ -72,19 +95,30 @@ class _AddItemWidgetState extends State<AddItemWidget> {
|
||||
color: appTheme.lightGray0,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: SvgPicture.asset('assets/icons/trash_can.svg', width: 20, height: 24),
|
||||
child: SvgPicture.asset(
|
||||
'assets/icons/trash_can.svg',
|
||||
width: 20,
|
||||
height: 24,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
if (state.validationState.optionsValidationState[state.selectedOptionIndex ?? 0].isItemsEmpty) ...[
|
||||
if (state
|
||||
.validationState
|
||||
.optionsValidationState[state.selectedOptionIndex ?? 0]
|
||||
.isItemsEmpty) ...[
|
||||
const SizedBox(height: 4),
|
||||
ValidationErrorWidget(error: s.addAtLeastOneItem)
|
||||
] else if (state.validationState.optionsValidationState[state.selectedOptionIndex ?? 0].itemsErrors[widget.index] ?? false) ...[
|
||||
ValidationErrorWidget(error: s.addAtLeastOneItem),
|
||||
] else if (state
|
||||
.validationState
|
||||
.optionsValidationState[state.selectedOptionIndex ?? 0]
|
||||
.itemsErrors[widget.index] ??
|
||||
false) ...[
|
||||
const SizedBox(height: 4),
|
||||
ValidationErrorWidget(error: s.atLeast3Symbols)
|
||||
]
|
||||
ValidationErrorWidget(error: s.atLeast3Symbols),
|
||||
],
|
||||
],
|
||||
);
|
||||
},
|
||||
|
||||
@@ -7,6 +7,7 @@ import 'package:eatvio_app/features/create_diet/presentation/widgets/error_widge
|
||||
import 'package:eatvio_app/features/create_plan/presentation/cubit/create_plan_cubit.dart';
|
||||
import 'package:eatvio_app/features/create_plan/presentation/cubit/create_plan_state.dart';
|
||||
import 'package:eatvio_app/features/create_plan/presentation/widgets/add_item_widget.dart';
|
||||
import 'package:eatvio_app/features/feed/models/plan_pagination_model.dart';
|
||||
import 'package:eatvio_app/generated/l10n.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
@@ -21,47 +22,107 @@ class OptionWidget extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _OptionWidgetState extends State<OptionWidget> {
|
||||
final firstOptionCustomerPriceController = TextEditingController()..text = '1200₽';
|
||||
final secondOptionCustomerPriceController = TextEditingController()..text = '1200₽';
|
||||
final thirdOptionCustomerPriceController = TextEditingController()..text = '1200₽';
|
||||
static const _optionsCount = 3;
|
||||
|
||||
final creatorPriceController = TextEditingController()..text = '1000₽';
|
||||
|
||||
final firstOptionTitleController = TextEditingController();
|
||||
final secondOptionTitleController = TextEditingController();
|
||||
final thirdOptionTitleController = TextEditingController();
|
||||
|
||||
final firstOptionDescriptionController = TextEditingController();
|
||||
final secondOptionDescriptionController = TextEditingController();
|
||||
final thirdOptionDescriptionController = TextEditingController();
|
||||
final List<TextEditingController> _customerPriceControllers = List.generate(
|
||||
_optionsCount,
|
||||
(_) => TextEditingController(text: '1200₽'),
|
||||
);
|
||||
final List<TextEditingController> _titleControllers = List.generate(
|
||||
_optionsCount,
|
||||
(_) => TextEditingController(),
|
||||
);
|
||||
final List<TextEditingController> _descriptionControllers = List.generate(
|
||||
_optionsCount,
|
||||
(_) => TextEditingController(),
|
||||
);
|
||||
final creatorPriceController = TextEditingController(text: '1000₽');
|
||||
|
||||
int? _syncedOptionIndex;
|
||||
bool firstExplanationVisible = false;
|
||||
bool isChecked = false;
|
||||
bool secondExplanationVisible = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
firstOptionCustomerPriceController.addListener(() {
|
||||
if (!firstOptionCustomerPriceController.text.endsWith('₽')) {
|
||||
final text = firstOptionCustomerPriceController.text.replaceAll('₽', '');
|
||||
firstOptionCustomerPriceController.text = '$text₽';
|
||||
firstOptionCustomerPriceController.selection = TextSelection.collapsed(offset: text.length);
|
||||
}
|
||||
});
|
||||
secondOptionCustomerPriceController.addListener(() {
|
||||
if (!secondOptionCustomerPriceController.text.endsWith('₽')) {
|
||||
final text = secondOptionCustomerPriceController.text.replaceAll('₽', '');
|
||||
secondOptionCustomerPriceController.text = '$text₽';
|
||||
secondOptionCustomerPriceController.selection = TextSelection.collapsed(offset: text.length);
|
||||
}
|
||||
});
|
||||
thirdOptionCustomerPriceController.addListener(() {
|
||||
if (!thirdOptionCustomerPriceController.text.endsWith('₽')) {
|
||||
final text = thirdOptionCustomerPriceController.text.replaceAll('₽', '');
|
||||
thirdOptionCustomerPriceController.text = '$text₽';
|
||||
thirdOptionCustomerPriceController.selection = TextSelection.collapsed(offset: text.length);
|
||||
}
|
||||
});
|
||||
for (final controller in _customerPriceControllers) {
|
||||
controller.addListener(() => _ensureRubleSuffix(controller));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
for (final controller in _customerPriceControllers) {
|
||||
controller.dispose();
|
||||
}
|
||||
for (final controller in _titleControllers) {
|
||||
controller.dispose();
|
||||
}
|
||||
for (final controller in _descriptionControllers) {
|
||||
controller.dispose();
|
||||
}
|
||||
creatorPriceController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _ensureRubleSuffix(TextEditingController controller) {
|
||||
if (!controller.text.endsWith('₽')) {
|
||||
final text = controller.text.replaceAll('₽', '');
|
||||
controller.text = '$text₽';
|
||||
controller.selection = TextSelection.collapsed(offset: text.length);
|
||||
}
|
||||
}
|
||||
|
||||
void _syncController(TextEditingController controller, String value) {
|
||||
if (controller.text == value) {
|
||||
return;
|
||||
}
|
||||
controller.value = TextEditingValue(
|
||||
text: value,
|
||||
selection: TextSelection.collapsed(offset: value.length),
|
||||
);
|
||||
}
|
||||
|
||||
String _formatCustomerPrice(dynamic price) {
|
||||
if (price is num) {
|
||||
return '${price.toStringAsFixed(0)}₽';
|
||||
}
|
||||
final parsed = double.tryParse(price.toString().replaceAll('₽', ''));
|
||||
return '${(parsed ?? 0).toStringAsFixed(0)}₽';
|
||||
}
|
||||
|
||||
void _applyOptionToControllers(int index, PacketOptions option) {
|
||||
_syncController(_titleControllers[index], option.short);
|
||||
_syncController(_descriptionControllers[index], option.description ?? '');
|
||||
_syncController(
|
||||
_customerPriceControllers[index],
|
||||
_formatCustomerPrice(option.price),
|
||||
);
|
||||
}
|
||||
|
||||
void _syncSelectedOption(CreatePlanState state) {
|
||||
final selectedOptionIndex = state.selectedOptionIndex ?? 0;
|
||||
final option = state.planData.options[selectedOptionIndex];
|
||||
final price = double.tryParse(
|
||||
_customerPriceControllers[selectedOptionIndex].text.replaceAll('₽', ''),
|
||||
);
|
||||
final creatorPrice = '${((price ?? 0) * 0.8).toStringAsFixed(2)}₽';
|
||||
|
||||
if (_syncedOptionIndex != selectedOptionIndex) {
|
||||
_syncedOptionIndex = selectedOptionIndex;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
_applyOptionToControllers(selectedOptionIndex, option);
|
||||
creatorPriceController.text = creatorPrice;
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (creatorPriceController.text != creatorPrice) {
|
||||
creatorPriceController.text = creatorPrice;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -70,31 +131,21 @@ class _OptionWidgetState extends State<OptionWidget> {
|
||||
return BlocBuilder<CreatePlanCubit, CreatePlanState>(
|
||||
builder: (context, state) {
|
||||
final selectedOptionIndex = state.selectedOptionIndex ?? 0;
|
||||
_syncSelectedOption(state);
|
||||
final optionNameHintText = switch (selectedOptionIndex) {
|
||||
0 => s.economy,
|
||||
2 => s.comfort,
|
||||
_ => s.standard,
|
||||
};
|
||||
final optionTitleController = switch (selectedOptionIndex) {
|
||||
0 => firstOptionTitleController,
|
||||
2 => thirdOptionTitleController,
|
||||
_ => secondOptionTitleController,
|
||||
};
|
||||
final optionDescriptionController = switch (selectedOptionIndex) {
|
||||
0 => firstOptionDescriptionController,
|
||||
2 => thirdOptionDescriptionController,
|
||||
_ => secondOptionDescriptionController,
|
||||
};
|
||||
final customerPriceController = switch (selectedOptionIndex) {
|
||||
0 => firstOptionCustomerPriceController,
|
||||
2 => thirdOptionCustomerPriceController,
|
||||
_ => secondOptionCustomerPriceController,
|
||||
};
|
||||
final optionTitleController = _titleControllers[selectedOptionIndex];
|
||||
final optionDescriptionController =
|
||||
_descriptionControllers[selectedOptionIndex];
|
||||
final customerPriceController =
|
||||
_customerPriceControllers[selectedOptionIndex];
|
||||
final optionItems = state.planData.options[selectedOptionIndex].items;
|
||||
final optionValidation =
|
||||
state.validationState.optionsValidationState[selectedOptionIndex];
|
||||
|
||||
final price = double.tryParse(customerPriceController.text.replaceAll('₽', ''));
|
||||
creatorPriceController.text = '${((price ?? 0) * 0.8).toStringAsFixed(2)}₽';
|
||||
firstOptionTitleController.text = state.planData.options[selectedOptionIndex].short;
|
||||
firstOptionDescriptionController.text = state.planData.options[selectedOptionIndex].description ?? '';
|
||||
return MainBackgroundBouble(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
@@ -110,14 +161,14 @@ class _OptionWidgetState extends State<OptionWidget> {
|
||||
const SizedBox(height: 4),
|
||||
CustomTextFormField(
|
||||
hintText: optionNameHintText,
|
||||
hasError: state.validationState.optionsValidationState[state.selectedOptionIndex ?? 0].titleHasError,
|
||||
hasError: optionValidation.titleHasError,
|
||||
controller: optionTitleController,
|
||||
onChanged: (value) {
|
||||
context.read<CreatePlanCubit>().setOptionTitle(value);
|
||||
},
|
||||
maxLength: 150,
|
||||
),
|
||||
if (state.validationState.optionsValidationState[state.selectedOptionIndex ?? 0].titleHasError) ...[
|
||||
if (optionValidation.titleHasError) ...[
|
||||
const SizedBox(height: 4),
|
||||
ValidationErrorWidget(error: s.atLeast3Symbols),
|
||||
],
|
||||
@@ -135,7 +186,7 @@ class _OptionWidgetState extends State<OptionWidget> {
|
||||
child: CustomTextFormField(
|
||||
controller: optionDescriptionController,
|
||||
textAlignVertical: TextAlignVertical.top,
|
||||
hasError: state.validationState.optionsValidationState[state.selectedOptionIndex ?? 0].descriptionHasError,
|
||||
hasError: optionValidation.descriptionHasError,
|
||||
maxLength: 1000,
|
||||
minLines: null,
|
||||
maxLines: null,
|
||||
@@ -146,7 +197,7 @@ class _OptionWidgetState extends State<OptionWidget> {
|
||||
},
|
||||
),
|
||||
),
|
||||
if (state.validationState.optionsValidationState[state.selectedOptionIndex ?? 0].descriptionHasError) ...[
|
||||
if (optionValidation.descriptionHasError) ...[
|
||||
const SizedBox(height: 4),
|
||||
ValidationErrorWidget(error: s.atLeast3Symbols),
|
||||
],
|
||||
@@ -158,7 +209,9 @@ class _OptionWidgetState extends State<OptionWidget> {
|
||||
textAlign: TextAlign.center,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
softWrap: true,
|
||||
style: TextStyles.textSMedium.copyWith(color: appTheme.black),
|
||||
style: TextStyles.textSMedium.copyWith(
|
||||
color: appTheme.black,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
const _PriceExplanationWidget(),
|
||||
@@ -179,7 +232,9 @@ class _OptionWidgetState extends State<OptionWidget> {
|
||||
textAlign: TextAlign.center,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
softWrap: true,
|
||||
style: TextStyles.textSMedium.copyWith(color: appTheme.black),
|
||||
style: TextStyles.textSMedium.copyWith(
|
||||
color: appTheme.black,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
const _PriceExplanationWidget(),
|
||||
@@ -188,8 +243,10 @@ class _OptionWidgetState extends State<OptionWidget> {
|
||||
const SizedBox(height: 4),
|
||||
CustomTextFormField(
|
||||
controller: customerPriceController,
|
||||
textInputType: const TextInputType.numberWithOptions(decimal: true),
|
||||
hasError: state.validationState.optionsValidationState[state.selectedOptionIndex ?? 0].priceHasError,
|
||||
textInputType: const TextInputType.numberWithOptions(
|
||||
decimal: true,
|
||||
),
|
||||
hasError: optionValidation.priceHasError,
|
||||
onChanged: (value) {
|
||||
final price = double.tryParse(value.replaceAll('₽', ''));
|
||||
if (price != null) {
|
||||
@@ -222,34 +279,41 @@ class _OptionWidgetState extends State<OptionWidget> {
|
||||
separatorBuilder: (context, index) => const SizedBox(height: 8),
|
||||
reverse: false,
|
||||
itemBuilder: (context, index) {
|
||||
final items = state.planData.options[selectedOptionIndex].items;
|
||||
|
||||
if (items.isEmpty) {
|
||||
if (optionItems.isEmpty) {
|
||||
return AddItemWidget(
|
||||
key: ValueKey(
|
||||
'plan-option-$selectedOptionIndex-item-$index',
|
||||
),
|
||||
index: index,
|
||||
canBeDeleted: false,
|
||||
enableHint: true,
|
||||
items: items,
|
||||
items: optionItems,
|
||||
);
|
||||
} else {
|
||||
if (index < items.length) {
|
||||
if (index < optionItems.length) {
|
||||
return AddItemWidget(
|
||||
key: ValueKey(
|
||||
'plan-option-$selectedOptionIndex-item-$index',
|
||||
),
|
||||
canBeDeleted: true,
|
||||
enableHint: false,
|
||||
index: index,
|
||||
items: items,
|
||||
items: optionItems,
|
||||
);
|
||||
} else {
|
||||
return AddItemWidget(
|
||||
key: ValueKey(
|
||||
'plan-option-$selectedOptionIndex-item-$index',
|
||||
),
|
||||
canBeDeleted: false,
|
||||
enableHint: false,
|
||||
index: index,
|
||||
items: items,
|
||||
items: optionItems,
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
itemCount: state.planData.options[selectedOptionIndex].items.length + 1,
|
||||
itemCount: optionItems.length + 1,
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
),
|
||||
@@ -259,23 +323,30 @@ class _OptionWidgetState extends State<OptionWidget> {
|
||||
Row(
|
||||
children: [
|
||||
Checkbox(
|
||||
value: state.planData.options[selectedOptionIndex].hasPersonalDiet,
|
||||
value: state
|
||||
.planData
|
||||
.options[selectedOptionIndex]
|
||||
.hasPersonalDiet,
|
||||
activeColor: appTheme.gray,
|
||||
checkColor: appTheme.gray,
|
||||
fillColor: const WidgetStatePropertyAll(Colors.transparent),
|
||||
side: WidgetStateBorderSide.resolveWith(
|
||||
(Set<WidgetState> states) {
|
||||
return BorderSide(width: 1, color: appTheme.gray);
|
||||
},
|
||||
),
|
||||
side: WidgetStateBorderSide.resolveWith((
|
||||
Set<WidgetState> states,
|
||||
) {
|
||||
return BorderSide(width: 1, color: appTheme.gray);
|
||||
}),
|
||||
onChanged: (bool? value) {
|
||||
context.read<CreatePlanCubit>().togglePersonalizedDietOptionState();
|
||||
context
|
||||
.read<CreatePlanCubit>()
|
||||
.togglePersonalizedDietOptionState();
|
||||
},
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
s.option_personal_diet_desc,
|
||||
style: TextStyles.textMMedium.copyWith(color: appTheme.black),
|
||||
style: TextStyles.textMMedium.copyWith(
|
||||
color: appTheme.black,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -292,7 +363,8 @@ class _PriceExplanationWidget extends StatefulWidget {
|
||||
const _PriceExplanationWidget();
|
||||
|
||||
@override
|
||||
State<_PriceExplanationWidget> createState() => _PriceExplanationWidgetState();
|
||||
State<_PriceExplanationWidget> createState() =>
|
||||
_PriceExplanationWidgetState();
|
||||
}
|
||||
|
||||
class _PriceExplanationWidgetState extends State<_PriceExplanationWidget> {
|
||||
@@ -314,9 +386,7 @@ class _PriceExplanationWidgetState extends State<_PriceExplanationWidget> {
|
||||
color: visible ? appTheme.whiteA700 : Colors.transparent,
|
||||
),
|
||||
duration: const Duration(milliseconds: 300),
|
||||
child: Text(
|
||||
s.price_explanation,
|
||||
),
|
||||
child: Text(s.price_explanation),
|
||||
),
|
||||
),
|
||||
visible: visible,
|
||||
@@ -331,7 +401,11 @@ class _PriceExplanationWidgetState extends State<_PriceExplanationWidget> {
|
||||
visible = !visible;
|
||||
});
|
||||
},
|
||||
child: SvgPicture.asset('assets/icons/question_mark.svg', height: 12, width: 12),
|
||||
child: SvgPicture.asset(
|
||||
'assets/icons/question_mark.svg',
|
||||
height: 12,
|
||||
width: 12,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -296,6 +296,7 @@ class _AboutDietScreenState extends State<AboutDietScreen> {
|
||||
actions: [
|
||||
CustomPopup(
|
||||
isAuthor: dietState.diet.author.authorIsMe,
|
||||
isAuthorAdmin: dietState.diet.author.isAdmin,
|
||||
path: 'diet/${dietState.diet.slug}',
|
||||
complainedContentType: ComplainedContent.diet,
|
||||
id: dietState.diet.id,
|
||||
|
||||
@@ -14,12 +14,21 @@ import 'package:flutter_svg/flutter_svg.dart';
|
||||
import 'package:share_plus/share_plus.dart';
|
||||
|
||||
class MoreOptions extends StatelessWidget {
|
||||
const MoreOptions({super.key, this.disableActions, required this.path, this.complainedContentType, this.slug, this.id});
|
||||
const MoreOptions({
|
||||
super.key,
|
||||
this.disableActions,
|
||||
required this.path,
|
||||
this.complainedContentType,
|
||||
this.slug,
|
||||
this.id,
|
||||
this.isAuthorAdmin = false,
|
||||
});
|
||||
final ComplainedContent? complainedContentType;
|
||||
final int? id;
|
||||
final String? slug;
|
||||
final String path;
|
||||
final bool? disableActions;
|
||||
final bool isAuthorAdmin;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -83,40 +92,42 @@ class MoreOptions extends StatelessWidget {
|
||||
],
|
||||
),
|
||||
),
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 12.0),
|
||||
child: CustomDivider(),
|
||||
),
|
||||
GestureDetector(
|
||||
onTap: disableActions == true
|
||||
? null
|
||||
: () {
|
||||
if (complainedContentType != null) {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
builder: (context) => ComplainingBottomSheet(
|
||||
complainedContentType: complainedContentType!,
|
||||
id: id,
|
||||
slug: slug,
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
s.complain,
|
||||
style: TextStyles.textMMedium.copyWith(
|
||||
color: appTheme.black,
|
||||
if (!isAuthorAdmin) ...[
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 12.0),
|
||||
child: CustomDivider(),
|
||||
),
|
||||
GestureDetector(
|
||||
onTap: disableActions == true
|
||||
? null
|
||||
: () {
|
||||
if (complainedContentType != null) {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
builder: (context) => ComplainingBottomSheet(
|
||||
complainedContentType: complainedContentType!,
|
||||
id: id,
|
||||
slug: slug,
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
s.complain,
|
||||
style: TextStyles.textMMedium.copyWith(
|
||||
color: appTheme.black,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
SvgPicture.asset('assets/icons/complain.svg', width: 16, height: 20),
|
||||
],
|
||||
SvgPicture.asset('assets/icons/complain.svg', width: 16, height: 20),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -86,6 +86,8 @@ int parseDuration(dynamic duration) {
|
||||
|
||||
@freezed
|
||||
abstract class Author with _$Author {
|
||||
const Author._();
|
||||
|
||||
const factory Author({
|
||||
@JsonKey(name: "author_slug") required String authorSlug,
|
||||
@JsonKey(name: "author_name") required String authorName,
|
||||
@@ -93,9 +95,12 @@ abstract class Author with _$Author {
|
||||
@JsonKey(name: "author_is_me") required bool authorIsMe,
|
||||
@JsonKey(name: "is_entity_subscribed_by_me") bool? isEntitySubscribedByMe,
|
||||
@JsonKey(name: "author_place_in_rating") int? authorPlaceInRating,
|
||||
@JsonKey(name: "author_roles") @Default([]) List<String> authorRoles,
|
||||
}) = _Author;
|
||||
|
||||
factory Author.fromJson(Map<String, dynamic> json) => _$AuthorFromJson(json);
|
||||
|
||||
bool get isAdmin => authorRoles.contains('admin');
|
||||
}
|
||||
|
||||
@freezed
|
||||
|
||||
@@ -26,16 +26,47 @@ class PostFeedMapper {
|
||||
totalViews: item.postViewsCount,
|
||||
totalComments: item.postCommentsCount,
|
||||
wasLikedByMe: item.postIsLikedByUser,
|
||||
images: item.images,
|
||||
images: item.images.where((image) => hasRenderableImage(image.url)).toList(),
|
||||
videoEmbedUrl: item.videoEmbedUrl,
|
||||
videoUrlThumb: item.videoUrlThumb,
|
||||
);
|
||||
}
|
||||
|
||||
static String resolveMediaUrl(String? url) {
|
||||
if (url == null || url.isEmpty) return '';
|
||||
if (url.startsWith('http://') || url.startsWith('https://')) return url;
|
||||
if (url.startsWith('/')) return '${sl<AppConfig>().baseUrl}$url';
|
||||
if (url == null || url.isEmpty || isPlaceholderMediaUrl(url)) {
|
||||
return '';
|
||||
}
|
||||
if (url.startsWith('http://') || url.startsWith('https://')) {
|
||||
return url;
|
||||
}
|
||||
if (url.startsWith('/')) {
|
||||
return '${sl<AppConfig>().baseUrl}$url';
|
||||
}
|
||||
return '${sl<AppConfig>().baseUrl}/storage/$url';
|
||||
}
|
||||
|
||||
static bool isPlaceholderMediaUrl(String url) {
|
||||
return url.toLowerCase().contains('element-image-placeholder');
|
||||
}
|
||||
|
||||
static bool hasRenderableImage(String? url) {
|
||||
if (url == null || url.trim().isEmpty) {
|
||||
return false;
|
||||
}
|
||||
return !isPlaceholderMediaUrl(url);
|
||||
}
|
||||
|
||||
static bool hasVisibleMedia({
|
||||
required String image,
|
||||
required List<FeedGalleryImage> images,
|
||||
String? videoEmbedUrl,
|
||||
}) {
|
||||
if (videoEmbedUrl != null && videoEmbedUrl.trim().isNotEmpty) {
|
||||
return true;
|
||||
}
|
||||
if (hasRenderableImage(image)) {
|
||||
return true;
|
||||
}
|
||||
return images.any((image) => hasRenderableImage(image.url));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -39,15 +39,17 @@ class BlogPostMediaContent extends StatefulWidget {
|
||||
class _BlogPostMediaContentState extends State<BlogPostMediaContent> {
|
||||
static const _counterDisplayDuration = Duration(seconds: 1);
|
||||
|
||||
/// Landscape / video slide aspect (width / height).
|
||||
/// Fallback while the real photo size is unknown, and for video-only.
|
||||
static const _horizontalAspectRatio = 16 / 9;
|
||||
|
||||
/// Portrait slide aspect (width / height).
|
||||
static const _verticalAspectRatio = 3 / 4;
|
||||
|
||||
bool _isCounterVisible = false;
|
||||
Timer? _counterHideTimer;
|
||||
|
||||
final Map<String, double> _aspectByUrl = {};
|
||||
final Map<String, ImageStream> _aspectStreams = {};
|
||||
final Map<String, ImageStreamListener> _aspectListeners = {};
|
||||
|
||||
bool get _hasVideo =>
|
||||
widget.videoEmbedUrl != null && widget.videoEmbedUrl!.trim().isNotEmpty;
|
||||
|
||||
@@ -82,7 +84,6 @@ class _BlogPostMediaContentState extends State<BlogPostMediaContent> {
|
||||
|
||||
bool get _shouldShowCounter => !_isCurrentSlideVideo || _isCounterVisible;
|
||||
|
||||
/// Height is fixed for the whole post from the first gallery image (web-like).
|
||||
String get _postOrientation {
|
||||
if (_gallery.isNotEmpty) {
|
||||
return _gallery.first.orientation;
|
||||
@@ -90,23 +91,89 @@ class _BlogPostMediaContentState extends State<BlogPostMediaContent> {
|
||||
return 'horizontal';
|
||||
}
|
||||
|
||||
double _heightForAspect(double aspectRatio, double width) {
|
||||
if (aspectRatio <= 0) {
|
||||
return width / _horizontalAspectRatio;
|
||||
}
|
||||
return width / aspectRatio;
|
||||
}
|
||||
|
||||
double _heightForOrientation(String orientation, double width) {
|
||||
final aspectRatio = orientation == 'vertical'
|
||||
? _verticalAspectRatio
|
||||
: _horizontalAspectRatio;
|
||||
final maxHeight = MediaQuery.sizeOf(context).height * 0.7;
|
||||
return (width / aspectRatio).clamp(180.0, maxHeight);
|
||||
return _heightForAspect(aspectRatio, width);
|
||||
}
|
||||
|
||||
/// Window height follows the current photo's real size, not a fake 16:9 / 3:4 box.
|
||||
double _mediaHeight(double width) {
|
||||
final gallery = _gallery;
|
||||
if (gallery.isEmpty) {
|
||||
return _heightForOrientation(_postOrientation, width);
|
||||
}
|
||||
|
||||
final index = widget.currentGalleryIndex;
|
||||
final image = index < gallery.length ? gallery[index] : gallery.first;
|
||||
final aspect = _aspectByUrl[image.url];
|
||||
if (aspect != null && aspect > 0) {
|
||||
return _heightForAspect(aspect, width);
|
||||
}
|
||||
return _heightForOrientation(image.orientation, width);
|
||||
}
|
||||
|
||||
void _syncAspectListeners() {
|
||||
final urls = _gallery.map((image) => image.url).toSet();
|
||||
for (final url in _aspectStreams.keys.toList()) {
|
||||
if (urls.contains(url)) {
|
||||
continue;
|
||||
}
|
||||
final stream = _aspectStreams.remove(url);
|
||||
final listener = _aspectListeners.remove(url);
|
||||
if (stream != null && listener != null) {
|
||||
stream.removeListener(listener);
|
||||
}
|
||||
_aspectByUrl.remove(url);
|
||||
}
|
||||
|
||||
for (final url in urls) {
|
||||
if (_aspectStreams.containsKey(url)) {
|
||||
continue;
|
||||
}
|
||||
final stream = CachedNetworkImageProvider(
|
||||
url,
|
||||
).resolve(ImageConfiguration.empty);
|
||||
final listener = ImageStreamListener((info, _) {
|
||||
final aspect = info.image.width / info.image.height;
|
||||
if (!mounted || aspect <= 0 || _aspectByUrl[url] == aspect) {
|
||||
return;
|
||||
}
|
||||
setState(() => _aspectByUrl[url] = aspect);
|
||||
}, onError: (_, _) {});
|
||||
stream.addListener(listener);
|
||||
_aspectStreams[url] = stream;
|
||||
_aspectListeners[url] = listener;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_syncAspectListeners();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_counterHideTimer?.cancel();
|
||||
for (final url in _aspectStreams.keys) {
|
||||
_aspectStreams[url]!.removeListener(_aspectListeners[url]!);
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant BlogPostMediaContent oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
_syncAspectListeners();
|
||||
if (oldWidget.currentGalleryIndex != widget.currentGalleryIndex) {
|
||||
_scheduleCounterHideOnVideo();
|
||||
}
|
||||
@@ -138,16 +205,26 @@ class _BlogPostMediaContentState extends State<BlogPostMediaContent> {
|
||||
return false;
|
||||
}
|
||||
|
||||
Widget _imageSlide(String itemUrl, int itemIndex, double height) {
|
||||
Widget _photo(String url, {required double width, double? height}) {
|
||||
return CachedNetworkImage(
|
||||
fit: BoxFit.fitWidth,
|
||||
alignment: Alignment.center,
|
||||
width: width,
|
||||
height: height,
|
||||
imageUrl: url,
|
||||
errorWidget: (context, str, object) => const SizedBox.shrink(),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _imageSlide(
|
||||
String itemUrl,
|
||||
int itemIndex, {
|
||||
required double width,
|
||||
required double height,
|
||||
}) {
|
||||
return GestureDetector(
|
||||
onTap: () => widget.onOpenGallery(_galleryUrls, itemIndex),
|
||||
child: CachedNetworkImage(
|
||||
fit: BoxFit.cover,
|
||||
width: MediaQuery.sizeOf(context).width,
|
||||
height: height,
|
||||
imageUrl: itemUrl,
|
||||
errorWidget: (context, str, object) => const SizedBox.shrink(),
|
||||
),
|
||||
child: _photo(itemUrl, width: width, height: height),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -175,6 +252,8 @@ class _BlogPostMediaContentState extends State<BlogPostMediaContent> {
|
||||
viewportFraction: 1,
|
||||
height: height,
|
||||
enableInfiniteScroll: false,
|
||||
disableCenter: true,
|
||||
padEnds: false,
|
||||
onPageChanged: (index, _) => widget.onGalleryChanged(index),
|
||||
),
|
||||
),
|
||||
@@ -200,42 +279,59 @@ class _BlogPostMediaContentState extends State<BlogPostMediaContent> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final width = constraints.maxWidth.isFinite
|
||||
? constraints.maxWidth
|
||||
: MediaQuery.sizeOf(context).width;
|
||||
return _buildMedia(width);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMedia(double width) {
|
||||
final gallery = _gallery;
|
||||
final width = MediaQuery.sizeOf(context).width;
|
||||
final mediaHeight = _heightForOrientation(_postOrientation, width);
|
||||
final mediaHeight = _mediaHeight(width);
|
||||
|
||||
if (gallery.length > 1 || (gallery.length == 1 && _hasVideo)) {
|
||||
final itemCount = _slideCount;
|
||||
return _carousel(
|
||||
itemCount: itemCount,
|
||||
height: mediaHeight,
|
||||
itemBuilder: (context, itemIndex) {
|
||||
if (itemIndex < gallery.length) {
|
||||
return _imageSlide(gallery[itemIndex].url, itemIndex, mediaHeight);
|
||||
}
|
||||
if (_hasVideo) {
|
||||
return _videoSlide();
|
||||
}
|
||||
return const SizedBox.shrink();
|
||||
},
|
||||
return AnimatedSize(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
curve: Curves.easeInOut,
|
||||
child: _carousel(
|
||||
itemCount: itemCount,
|
||||
height: mediaHeight,
|
||||
itemBuilder: (context, itemIndex) {
|
||||
if (itemIndex < gallery.length) {
|
||||
return _imageSlide(
|
||||
gallery[itemIndex].url,
|
||||
itemIndex,
|
||||
width: width,
|
||||
height: mediaHeight,
|
||||
);
|
||||
}
|
||||
if (_hasVideo) {
|
||||
return _videoSlide();
|
||||
}
|
||||
return const SizedBox.shrink();
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (gallery.length == 1) {
|
||||
return _imageSlide(gallery.first.url, 0, mediaHeight);
|
||||
return GestureDetector(
|
||||
onTap: () => widget.onOpenGallery(_galleryUrls, 0),
|
||||
child: _photo(gallery.first.url, width: width),
|
||||
);
|
||||
}
|
||||
|
||||
if (_hasVideo) {
|
||||
return SizedBox(height: mediaHeight, width: width, child: _videoSlide());
|
||||
}
|
||||
|
||||
if (widget.imageUrl.isNotEmpty) {
|
||||
final image = CachedNetworkImage(
|
||||
fit: BoxFit.fitWidth,
|
||||
width: width,
|
||||
imageUrl: widget.imageUrl,
|
||||
errorWidget: (context, str, object) => const SizedBox.shrink(),
|
||||
);
|
||||
if (PostFeedMapper.hasRenderableImage(widget.imageUrl)) {
|
||||
final image = _photo(widget.imageUrl, width: width);
|
||||
return GestureDetector(
|
||||
onTap: () => widget.onOpenGallery([widget.imageUrl], 0),
|
||||
child: widget.heroTag == null
|
||||
|
||||
@@ -83,6 +83,11 @@ class _BlogPostWidgetState extends State<BlogPostWidget> {
|
||||
Widget build(BuildContext context) {
|
||||
final imageUrl = PostFeedMapper.resolveMediaUrl(post.image);
|
||||
final embedUrl = post.videoEmbedUrl?.trim();
|
||||
final hasMedia = PostFeedMapper.hasVisibleMedia(
|
||||
image: imageUrl,
|
||||
images: post.images,
|
||||
videoEmbedUrl: embedUrl,
|
||||
);
|
||||
|
||||
return InkWell(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
@@ -97,30 +102,27 @@ class _BlogPostWidgetState extends State<BlogPostWidget> {
|
||||
children: [
|
||||
const SizedBox(height: 12),
|
||||
_BlogPostHeader(post: post),
|
||||
_BlogPostTextContent(post: post),
|
||||
BlogPostMediaContent(
|
||||
galleryImages: post.images,
|
||||
imageUrl: imageUrl,
|
||||
videoEmbedUrl: embedUrl != null && embedUrl.isNotEmpty
|
||||
? embedUrl
|
||||
: null,
|
||||
videoUrlThumb: post.videoUrlThumb,
|
||||
currentGalleryIndex: currentGalleryIndex,
|
||||
heroTag: 'feed-blog-post-${post.id}-cover',
|
||||
onOpenGallery: (urls, index) => _openGallery(
|
||||
urls,
|
||||
index,
|
||||
heroTag: urls.length == 1
|
||||
? 'feed-blog-post-${post.id}-cover'
|
||||
: null,
|
||||
_BlogPostTextContent(post: post, includeBottomSpacing: hasMedia),
|
||||
if (hasMedia)
|
||||
BlogPostMediaContent(
|
||||
galleryImages: post.images,
|
||||
imageUrl: imageUrl,
|
||||
videoEmbedUrl: embedUrl != null && embedUrl.isNotEmpty ? embedUrl : null,
|
||||
videoUrlThumb: post.videoUrlThumb,
|
||||
currentGalleryIndex: currentGalleryIndex,
|
||||
heroTag: 'feed-blog-post-${post.id}-cover',
|
||||
onOpenGallery: (urls, index) => _openGallery(
|
||||
urls,
|
||||
index,
|
||||
heroTag: urls.length == 1 ? 'feed-blog-post-${post.id}-cover' : null,
|
||||
),
|
||||
onGalleryChanged: (index) {
|
||||
setState(() {
|
||||
currentGalleryIndex = index;
|
||||
});
|
||||
},
|
||||
),
|
||||
onGalleryChanged: (index) {
|
||||
setState(() {
|
||||
currentGalleryIndex = index;
|
||||
});
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
SizedBox(height: hasMedia ? 12 : 8),
|
||||
_BlogPostActionsRow(
|
||||
post: post,
|
||||
isLiked: isLiked,
|
||||
@@ -199,6 +201,7 @@ class _BlogPostHeader extends StatelessWidget {
|
||||
),
|
||||
CustomPopup(
|
||||
isAuthor: post.author.authorIsMe,
|
||||
isAuthorAdmin: post.author.isAdmin,
|
||||
complainedContentType: ComplainedContent.posts,
|
||||
path: 'blog_post/${post.id}',
|
||||
id: post.id,
|
||||
@@ -212,9 +215,10 @@ class _BlogPostHeader extends StatelessWidget {
|
||||
}
|
||||
|
||||
class _BlogPostTextContent extends StatelessWidget {
|
||||
const _BlogPostTextContent({required this.post});
|
||||
const _BlogPostTextContent({required this.post, required this.includeBottomSpacing});
|
||||
|
||||
final FeedBlogPostModel post;
|
||||
final bool includeBottomSpacing;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -258,7 +262,7 @@ class _BlogPostTextContent extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
),
|
||||
if (post.title.isNotEmpty || post.description.isNotEmpty)
|
||||
if (includeBottomSpacing && (post.title.isNotEmpty || post.description.isNotEmpty))
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:eatvio_app/core/app_config.dart';
|
||||
import 'package:eatvio_app/core/locator_service.dart';
|
||||
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
||||
import 'package:eatvio_app/core/widgets/network_svg_picture.dart';
|
||||
import 'package:eatvio_app/features/feed/models/feed_consultant_model.dart';
|
||||
import 'package:eatvio_app/features/profile/models/badge_model.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
@@ -92,7 +93,7 @@ class ConsultantWidget extends StatelessWidget {
|
||||
children: [
|
||||
for (int i = 0; i < badges.length; i++) ...[
|
||||
badges[i].image.contains('svg')
|
||||
? SvgPicture.network('${sl<AppConfig>().baseUrl}/storage/${badges[i].image}', width: 28, height: 28)
|
||||
? NetworkSvgPicture(url: '${sl<AppConfig>().baseUrl}/storage/${badges[i].image}', width: 28, height: 28)
|
||||
: CachedNetworkImage(imageUrl: '${sl<AppConfig>().baseUrl}/storage/${badges[i].image}', width: 28, height: 28),
|
||||
i < badges.length - 1 ? const SizedBox(width: 8) : const SizedBox.shrink(),
|
||||
],
|
||||
|
||||
@@ -80,6 +80,7 @@ class _DietsWidgetState extends State<DietsWidget> {
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
child: CustomPopup(
|
||||
isAuthor: widget.diet.author.authorIsMe,
|
||||
isAuthorAdmin: widget.diet.author.isAdmin,
|
||||
path: 'diet/${widget.diet.slug}',
|
||||
id: widget.diet.id,
|
||||
complainedContentType: ComplainedContent.diet,
|
||||
|
||||
@@ -85,6 +85,7 @@ class _PlanWidgetState extends State<PlanWidget> {
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
child: CustomPopup(
|
||||
isAuthor: widget.plan.author.authorIsMe,
|
||||
isAuthorAdmin: widget.plan.author.isAdmin,
|
||||
path: 'plan/${widget.plan.slug}',
|
||||
id: widget.plan.id,
|
||||
slug: widget.plan.slug,
|
||||
|
||||
@@ -115,6 +115,7 @@ class _RecipesWidgetState extends State<RecipesWidget> {
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
child: CustomPopup(
|
||||
isAuthor: widget.recipe.author.authorIsMe,
|
||||
isAuthorAdmin: widget.recipe.author.isAdmin,
|
||||
path: 'recipe/${widget.recipe.slug}',
|
||||
slug: widget.recipe.slug,
|
||||
complainedContentType: ComplainedContent.recipe,
|
||||
|
||||
@@ -1,8 +1,20 @@
|
||||
import 'package:eatvio_app/core/app_config.dart';
|
||||
import 'package:eatvio_app/core/locator_service.dart';
|
||||
import 'package:eatvio_app/core/widgets/loader.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:webview_flutter/webview_flutter.dart';
|
||||
|
||||
const _passKeyLoginPath = 'login_with_pass_key';
|
||||
const _contentReadyProgress = 40;
|
||||
|
||||
@visibleForTesting
|
||||
bool shouldHideGlossaryLoader({required String url, required int progress}) {
|
||||
if (url.isEmpty || url.contains(_passKeyLoginPath)) {
|
||||
return false;
|
||||
}
|
||||
return progress >= _contentReadyProgress;
|
||||
}
|
||||
|
||||
class GlossaryScreen extends StatefulWidget {
|
||||
const GlossaryScreen({super.key, required this.passKey});
|
||||
final String passKey;
|
||||
@@ -12,6 +24,8 @@ class GlossaryScreen extends StatefulWidget {
|
||||
|
||||
class _GlossaryScreenState extends State<GlossaryScreen> {
|
||||
late final WebViewController _controller;
|
||||
bool _isLoading = true;
|
||||
String _currentUrl = '';
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -19,14 +33,47 @@ class _GlossaryScreenState extends State<GlossaryScreen> {
|
||||
|
||||
_controller = WebViewController()
|
||||
..setJavaScriptMode(JavaScriptMode.unrestricted)
|
||||
..setNavigationDelegate(
|
||||
NavigationDelegate(
|
||||
onPageStarted: (url) {
|
||||
_currentUrl = url;
|
||||
},
|
||||
onProgress: (progress) {
|
||||
if (shouldHideGlossaryLoader(url: _currentUrl, progress: progress)) {
|
||||
_hideLoader();
|
||||
}
|
||||
},
|
||||
onPageFinished: (url) {
|
||||
if (shouldHideGlossaryLoader(url: url, progress: 100)) {
|
||||
_hideLoader();
|
||||
}
|
||||
},
|
||||
),
|
||||
)
|
||||
..loadRequest(Uri.parse('${sl<AppConfig>().baseUrl}/login_with_pass_key?id=${widget.passKey}'));
|
||||
}
|
||||
|
||||
void _hideLoader() {
|
||||
if (!_isLoading || !mounted) {
|
||||
return;
|
||||
}
|
||||
setState(() => _isLoading = false);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Глоссарий')),
|
||||
body: WebViewWidget(controller: _controller),
|
||||
body: Stack(
|
||||
children: [
|
||||
WebViewWidget(controller: _controller),
|
||||
if (_isLoading)
|
||||
ColoredBox(
|
||||
color: Theme.of(context).scaffoldBackgroundColor,
|
||||
child: const SizedBox.expand(child: Loader()),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -369,7 +369,7 @@ class _ChosenPlanFinishedToConsultantWidget extends StatelessWidget {
|
||||
buttonTextStyle: TextStyles.textMSemibold.copyWith(color: appTheme.whiteA700),
|
||||
buttonStyle: ButtonStyle(side: const WidgetStatePropertyAll(BorderSide.none), backgroundColor: WidgetStatePropertyAll(appTheme.green)),
|
||||
onPressed: () {
|
||||
GoRouter.of(context).push(CardSettingsScreen.path, extra: notification.planId);
|
||||
GoRouter.of(context).push(CardSettingsScreen.path, extra: notification.chosenPlanId);
|
||||
},
|
||||
),
|
||||
],
|
||||
|
||||
@@ -34,16 +34,28 @@ class InfoByOrderScreen extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
return BlocProvider(
|
||||
create: (context) => sl<OrderInfoCubit>()..getChosenPlan(id: planId, customerSlug: customerSlug, isConsultantView: isConsultantView),
|
||||
child: InfoByOrderScreenContent(successfulPayment: successfulPayment, planId: planId, customerSlug: customerSlug),
|
||||
child: InfoByOrderScreenContent(
|
||||
successfulPayment: successfulPayment,
|
||||
planId: planId,
|
||||
customerSlug: customerSlug,
|
||||
isConsultantView: isConsultantView,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class InfoByOrderScreenContent extends StatefulWidget {
|
||||
const InfoByOrderScreenContent({super.key, required this.planId, required this.successfulPayment, this.customerSlug});
|
||||
const InfoByOrderScreenContent({
|
||||
super.key,
|
||||
required this.planId,
|
||||
required this.successfulPayment,
|
||||
this.customerSlug,
|
||||
this.isConsultantView = false,
|
||||
});
|
||||
final int planId;
|
||||
final bool? successfulPayment;
|
||||
final String? customerSlug;
|
||||
final bool isConsultantView;
|
||||
@override
|
||||
State<InfoByOrderScreenContent> createState() => _InfoByOrderScreenContentState();
|
||||
}
|
||||
@@ -131,7 +143,9 @@ class _InfoByOrderScreenContentState extends State<InfoByOrderScreenContent> {
|
||||
body: SingleChildScrollView(
|
||||
child: Column(
|
||||
children: [
|
||||
if (plan.chosenPlanState == ChosenPlanStateEnum.stateDone) ...[CollectChargeBanner(chosenPlan: plan, price: chosenOption?.price ?? 0)],
|
||||
if (plan.chosenPlanState == ChosenPlanStateEnum.stateDone && (widget.customerSlug != null || widget.isConsultantView)) ...[
|
||||
CollectChargeBanner(chosenPlan: plan, price: chosenOption?.price ?? 0),
|
||||
],
|
||||
if (widget.successfulPayment != null) ...[const SizedBox(height: 8), PaymentBanner(successfulPayment: widget.successfulPayment ?? false)],
|
||||
const SizedBox(height: 8),
|
||||
AuthorCard(
|
||||
|
||||
@@ -7,12 +7,21 @@ import 'package:eatvio_app/features/diets/repositories/diet_repository.dart';
|
||||
import 'package:eatvio_app/features/plans/models/chosen_plan_model.dart';
|
||||
import 'package:eatvio_app/features/plans/presentation/cubit/about_plan_state.dart';
|
||||
import 'package:eatvio_app/features/plans/repositories/plan_repository.dart';
|
||||
import 'package:eatvio_app/features/profile/models/chosen_diets_pagination_model.dart';
|
||||
import 'package:eatvio_app/features/profile/repositories/profile_repository.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:eatvio_app/core/error_reporting/report_from.dart';
|
||||
|
||||
class AboutPlanCubit extends Cubit<AboutPlanState> {
|
||||
final PlanRepository planRepository;
|
||||
AboutPlanCubit({required this.planRepository}) : super(AboutPlanState.initial());
|
||||
final ProfileRepository profileRepository;
|
||||
final DietRepository dietRepository;
|
||||
|
||||
AboutPlanCubit({
|
||||
required this.planRepository,
|
||||
required this.profileRepository,
|
||||
required this.dietRepository,
|
||||
}) : super(AboutPlanState.initial());
|
||||
|
||||
Future<void> getPlan({required String slug, int? index}) async {
|
||||
try {
|
||||
@@ -141,6 +150,38 @@ class AboutPlanCubit extends Cubit<AboutPlanState> {
|
||||
return (null, '');
|
||||
}
|
||||
|
||||
/// Активная публичная диета (без привязки к плану) в статусах inited/started.
|
||||
Future<ChosenDietModel?> getActivePublicDiet() async {
|
||||
try {
|
||||
final chosenDiets = await profileRepository.getChosenDiets();
|
||||
final lastChosenDiet = chosenDiets.firstOrNull;
|
||||
if (lastChosenDiet == null) {
|
||||
return null;
|
||||
}
|
||||
final isActive = ['inited', 'started'].contains(lastChosenDiet.state);
|
||||
final isPublic = lastChosenDiet.chosenPlanId == 0;
|
||||
if (isActive && isPublic && lastChosenDiet.dietId != null) {
|
||||
return lastChosenDiet;
|
||||
}
|
||||
return null;
|
||||
} catch (e, st) {
|
||||
await reportFrom(e, st, this);
|
||||
log(e.toString(), stackTrace: st);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> cancelPublicDiet(int dietId) async {
|
||||
try {
|
||||
await dietRepository.cancelDiet(dietId);
|
||||
return true;
|
||||
} catch (e, st) {
|
||||
await reportFrom(e, st, this);
|
||||
log(e.toString(), stackTrace: st);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
String _getOptionNameFromIndex(int index) => switch (index) {
|
||||
1 => 'options2',
|
||||
2 => 'options3',
|
||||
|
||||
@@ -17,8 +17,10 @@ import 'package:eatvio_app/core/comments/comments_cubit/comments_cubit.dart';
|
||||
import 'package:eatvio_app/core/comments/comments_cubit/comments_state.dart';
|
||||
import 'package:eatvio_app/features/feed/presentation/bloc/feed_cubit/feed_cubit.dart';
|
||||
import 'package:eatvio_app/features/feed/presentation/widgets/comments_widget.dart';
|
||||
import 'package:eatvio_app/core/utils/toasts.dart';
|
||||
import 'package:eatvio_app/features/plans/presentation/cubit/about_plan_cubit.dart';
|
||||
import 'package:eatvio_app/features/plans/presentation/cubit/about_plan_state.dart';
|
||||
import 'package:eatvio_app/features/plans/presentation/widgets/confirm_start_plan_bottom_sheet.dart';
|
||||
import 'package:eatvio_app/features/plans/presentation/widgets/options_tab.dart';
|
||||
import 'package:eatvio_app/generated/l10n.dart';
|
||||
import 'package:eatvio_app/core/locator_service.dart';
|
||||
@@ -73,6 +75,51 @@ class _AboutPlanScreenState extends State<AboutPlanScreen> {
|
||||
scrollController.jumpTo(scrollController.position.maxScrollExtent);
|
||||
}
|
||||
|
||||
Future<void> _onOrderPressed(BuildContext context) async {
|
||||
final cubit = context.read<AboutPlanCubit>();
|
||||
final publicDiet = await cubit.getActivePublicDiet();
|
||||
if (!context.mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (publicDiet != null) {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
builder: (_) => ConfirmStartPlanBottomSheet(
|
||||
onConfirm: () => _submitOrder(context, cubit, publicDietId: publicDiet.dietId),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await _submitOrder(context, cubit);
|
||||
}
|
||||
|
||||
Future<void> _submitOrder(BuildContext context, AboutPlanCubit cubit, {int? publicDietId}) async {
|
||||
final s = S.of(context);
|
||||
if (publicDietId != null) {
|
||||
final cancelled = await cubit.cancelPublicDiet(publicDietId);
|
||||
if (!cancelled) {
|
||||
showErrorToast('Не удалось отменить диету');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
final (chosenPlan, error) = await cubit.chosePlan();
|
||||
if (!context.mounted) {
|
||||
return;
|
||||
}
|
||||
if (error == 'hasPlan') {
|
||||
showErrorDialog(context, title: s.end_previous_plan, description: s.you_can_not_start_plan_until_end_of_prev_one);
|
||||
return;
|
||||
}
|
||||
if (chosenPlan == null) {
|
||||
showErrorDialog(context, title: 'Не удалось заказать план');
|
||||
return;
|
||||
}
|
||||
GoRouter.of(context).pushReplacementNamed('chatScreen', extra: {'uuid': chosenPlan.chatId, 'sendInitialMessage': true});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final s = S.of(context);
|
||||
@@ -147,6 +194,7 @@ class _AboutPlanScreenState extends State<AboutPlanScreen> {
|
||||
actions: [
|
||||
CustomPopup(
|
||||
isAuthor: state.planModel?.author.authorIsMe ?? false,
|
||||
isAuthorAdmin: state.planModel?.author.isAdmin ?? false,
|
||||
path: 'plan/${widget.slug}',
|
||||
complainedContentType: ComplainedContent.plan,
|
||||
userSlug: state.planModel?.author.authorSlug ?? '',
|
||||
@@ -182,20 +230,7 @@ class _AboutPlanScreenState extends State<AboutPlanScreen> {
|
||||
focusElevation: 0,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
backgroundColor: appTheme.greenA700,
|
||||
onPressed: () async {
|
||||
final (chosenPlan, error) = await context.read<AboutPlanCubit>().chosePlan();
|
||||
if (error == 'hasPlan') {
|
||||
showErrorDialog(context, title: s.end_previous_plan, description: s.you_can_not_start_plan_until_end_of_prev_one);
|
||||
return;
|
||||
}
|
||||
if (chosenPlan == null) {
|
||||
if (!context.mounted) return;
|
||||
showErrorDialog(context, title: 'Не удалось заказать план');
|
||||
return;
|
||||
}
|
||||
if (!context.mounted) return;
|
||||
GoRouter.of(context).pushReplacementNamed('chatScreen', extra: {'uuid': chosenPlan.chatId, 'sendInitialMessage': true});
|
||||
},
|
||||
onPressed: () => _onOrderPressed(context),
|
||||
label: Text(s.to_order, style: TextStyles.textMSemibold.copyWith(color: appTheme.whiteA700)),
|
||||
),
|
||||
),
|
||||
@@ -339,6 +374,7 @@ class _AboutPlanScreenState extends State<AboutPlanScreen> {
|
||||
actions: [
|
||||
CustomPopup(
|
||||
isAuthor: state.planModel?.author.authorIsMe ?? false,
|
||||
isAuthorAdmin: state.planModel?.author.isAdmin ?? false,
|
||||
path: 'plan/${widget.slug}',
|
||||
complainedContentType: ComplainedContent.plan,
|
||||
userSlug: state.planModel?.author.authorSlug ?? '',
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import 'package:eatvio_app/core/theme/custom_text_style.dart';
|
||||
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
||||
import 'package:eatvio_app/core/widgets/bottom_sheet_handler_bar.dart';
|
||||
import 'package:eatvio_app/core/widgets/custom_outlined_button.dart';
|
||||
import 'package:eatvio_app/generated/l10n.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class ConfirmStartPlanBottomSheet extends StatelessWidget {
|
||||
const ConfirmStartPlanBottomSheet({super.key, required this.onConfirm});
|
||||
|
||||
final Future<void> Function() onConfirm;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final s = S.of(context);
|
||||
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const SizedBox(height: 4),
|
||||
const BottomSheetHandlerBar(),
|
||||
const SizedBox(height: 12),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 25.0),
|
||||
child: Text(
|
||||
'Вы уверены, что хотите начать прохождение плана?',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 16, right: 16, top: 16, bottom: 28),
|
||||
child: CustomOutlinedButton(
|
||||
onPressed: () async {
|
||||
await Navigator.of(context).maybePop();
|
||||
await onConfirm();
|
||||
},
|
||||
height: 60,
|
||||
text: s.confirm,
|
||||
buttonTextStyle: TextStyles.textLSemibold.copyWith(color: appTheme.whiteA700),
|
||||
buttonStyle: ButtonStyle(
|
||||
backgroundColor: WidgetStatePropertyAll(appTheme.blueA400),
|
||||
side: const WidgetStatePropertyAll(BorderSide.none),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,7 @@ abstract class ChosenDietsPaginationModel with _$ChosenDietsPaginationModel {
|
||||
abstract class ChosenDietModel with _$ChosenDietModel {
|
||||
factory ChosenDietModel({
|
||||
@JsonKey(name: 'id') required int chosenDietID,
|
||||
@JsonKey(name: 'chosen_plan_id') required int chosenPlanId,
|
||||
@JsonKey(name: 'chosen_plan_id', fromJson: _parseChosenPlanId) required int chosenPlanId,
|
||||
@JsonKey() required String state,
|
||||
@JsonKey(name: 'start_date', fromJson: parseDate) required DateTime startDate,
|
||||
@JsonKey(name: 'diet', fromJson: _parseDietId) required int? dietId,
|
||||
@@ -30,3 +30,9 @@ int? _parseDietId(dynamic json) {
|
||||
if (json == null) return null;
|
||||
return json['id'] as int?;
|
||||
}
|
||||
|
||||
int _parseChosenPlanId(dynamic json) {
|
||||
if (json == null) return 0;
|
||||
if (json is num) return json.toInt();
|
||||
return int.tryParse(json.toString()) ?? 0;
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import 'package:eatvio_app/core/utils/date_time_utils.dart';
|
||||
import 'package:eatvio_app/core/widgets/bottom_sheet_with_blur.dart';
|
||||
import 'package:eatvio_app/core/widgets/data_error_widget.dart';
|
||||
import 'package:eatvio_app/core/widgets/loader.dart';
|
||||
import 'package:eatvio_app/core/widgets/network_svg_picture.dart';
|
||||
import 'package:eatvio_app/features/bottom_navigation_bar/presentation/bloc/profile_avatar_cubit/profile_avatar_cubit.dart';
|
||||
import 'package:eatvio_app/features/chat/presentation/pages/photo_view_screen.dart';
|
||||
import 'package:eatvio_app/features/complaining/presentation/complaining_bottom_sheet.dart';
|
||||
@@ -26,6 +27,7 @@ import 'package:eatvio_app/features/profile/presentation/bloc/profile_cubit/prof
|
||||
import 'package:eatvio_app/features/profile/presentation/bloc/sub_cubit/sub_cubit.dart';
|
||||
import 'package:eatvio_app/features/profile/presentation/pages/subs_screen.dart';
|
||||
import 'package:eatvio_app/features/profile/presentation/widgets/circumference_chart_widget.dart';
|
||||
import 'package:eatvio_app/features/profile/presentation/widgets/expandable_page_view.dart';
|
||||
import 'package:eatvio_app/features/profile/presentation/widgets/imt_chart_widget.dart';
|
||||
import 'package:eatvio_app/features/profile/presentation/widgets/profile_blog_post_widget.dart';
|
||||
import 'package:eatvio_app/features/profile/presentation/widgets/toggle_cert_photo_bottom_sheet.dart';
|
||||
@@ -382,19 +384,16 @@ class _ProfileScreenState extends State<ProfileScreen> {
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
SizedBox(
|
||||
height: 781,
|
||||
child: PageView.builder(
|
||||
itemBuilder: (BuildContext context, int index) {
|
||||
return PlanWidget(plan: state.plansList[index], index: index);
|
||||
},
|
||||
onPageChanged: (bullet) {
|
||||
setState(() {
|
||||
_currentBullet = bullet;
|
||||
});
|
||||
},
|
||||
itemCount: state.plansList.length,
|
||||
),
|
||||
ExpandablePageView(
|
||||
itemCount: state.plansList.length,
|
||||
onPageChanged: (bullet) {
|
||||
setState(() {
|
||||
_currentBullet = bullet;
|
||||
});
|
||||
},
|
||||
itemBuilder: (BuildContext context, int index) {
|
||||
return PlanWidget(plan: state.plansList[index], index: index);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_bullets(_currentBullet, state.plansList.length),
|
||||
@@ -779,7 +778,11 @@ class _ProfileScreenState extends State<ProfileScreen> {
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
boxShadow: [BoxShadow(color: Colors.black.withAlpha((255 * 0.2).toInt()), blurRadius: 32)],
|
||||
),
|
||||
child: SvgPicture.network('${sl<AppConfig>().baseUrl}/storage/${state.profile.badges[i].image}', width: 40, height: 40),
|
||||
child: NetworkSvgPicture(
|
||||
url: '${sl<AppConfig>().baseUrl}/storage/${state.profile.badges[i].image}',
|
||||
width: 40,
|
||||
height: 40,
|
||||
),
|
||||
)
|
||||
: Tooltip(
|
||||
preferBelow: true,
|
||||
@@ -1105,7 +1108,7 @@ class _ProfileScreenState extends State<ProfileScreen> {
|
||||
);
|
||||
},
|
||||
),
|
||||
if (!state.isMyProfile) ...[
|
||||
if (!state.isMyProfile && !state.profile.roles.contains('admin')) ...[
|
||||
_buildDivider(),
|
||||
_buildCompactItem(
|
||||
icon: 'assets/icons/complain.svg',
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class ExpandablePageView extends StatefulWidget {
|
||||
const ExpandablePageView({
|
||||
super.key,
|
||||
required this.itemCount,
|
||||
required this.itemBuilder,
|
||||
this.onPageChanged,
|
||||
});
|
||||
|
||||
final int itemCount;
|
||||
final IndexedWidgetBuilder itemBuilder;
|
||||
final ValueChanged<int>? onPageChanged;
|
||||
|
||||
@override
|
||||
State<ExpandablePageView> createState() => _ExpandablePageViewState();
|
||||
}
|
||||
|
||||
class _ExpandablePageViewState extends State<ExpandablePageView> {
|
||||
late List<double> _heights;
|
||||
int _currentPage = 0;
|
||||
|
||||
double get _currentHeight {
|
||||
if (_heights.isEmpty) {
|
||||
return 0;
|
||||
}
|
||||
final page = _currentPage.clamp(0, _heights.length - 1);
|
||||
return _heights[page];
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_heights = List<double>.filled(widget.itemCount, 0, growable: true);
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant ExpandablePageView oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (widget.itemCount != oldWidget.itemCount) {
|
||||
_heights = List<double>.filled(widget.itemCount, 0, growable: true);
|
||||
if (widget.itemCount == 0) {
|
||||
_currentPage = 0;
|
||||
} else {
|
||||
_currentPage = _currentPage.clamp(0, widget.itemCount - 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox(
|
||||
height: _currentHeight,
|
||||
child: PageView.builder(
|
||||
itemCount: widget.itemCount,
|
||||
onPageChanged: (int index) {
|
||||
setState(() {
|
||||
_currentPage = index;
|
||||
});
|
||||
widget.onPageChanged?.call(index);
|
||||
},
|
||||
itemBuilder: (BuildContext context, int index) {
|
||||
return OverflowBox(
|
||||
alignment: Alignment.topCenter,
|
||||
minHeight: 0,
|
||||
maxHeight: double.infinity,
|
||||
child: _SizeReportingWidget(
|
||||
onSizeChange: (Size size) {
|
||||
if (index >= _heights.length ||
|
||||
_heights[index] == size.height) {
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_heights[index] = size.height;
|
||||
});
|
||||
},
|
||||
child: widget.itemBuilder(context, index),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SizeReportingWidget extends StatefulWidget {
|
||||
const _SizeReportingWidget({required this.child, required this.onSizeChange});
|
||||
|
||||
final Widget child;
|
||||
final ValueChanged<Size> onSizeChange;
|
||||
|
||||
@override
|
||||
State<_SizeReportingWidget> createState() => _SizeReportingWidgetState();
|
||||
}
|
||||
|
||||
class _SizeReportingWidgetState extends State<_SizeReportingWidget> {
|
||||
Size? _oldSize;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _notifySize());
|
||||
return widget.child;
|
||||
}
|
||||
|
||||
void _notifySize() {
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
final size = context.size;
|
||||
if (size != null && _oldSize != size) {
|
||||
_oldSize = size;
|
||||
widget.onSizeChange(size);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -60,6 +60,11 @@ class _ProfileBlogPostWidgetState extends State<ProfileBlogPostWidget> {
|
||||
totalLikes = post.totalLikes;
|
||||
final imageUrl = PostFeedMapper.resolveMediaUrl(post.image);
|
||||
final embedUrl = post.videoEmbedUrl?.trim();
|
||||
final hasMedia = PostFeedMapper.hasVisibleMedia(
|
||||
image: imageUrl,
|
||||
images: post.images,
|
||||
videoEmbedUrl: embedUrl,
|
||||
);
|
||||
|
||||
return InkWell(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
@@ -79,44 +84,17 @@ class _ProfileBlogPostWidgetState extends State<ProfileBlogPostWidget> {
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
CircleAvatar(
|
||||
radius: 20,
|
||||
backgroundColor: Colors.black12,
|
||||
foregroundImage: NetworkImage(
|
||||
post.author.authorProfileIcon.contains('https')
|
||||
? post.author.authorProfileIcon
|
||||
: sl<AppConfig>().baseUrl +
|
||||
post.author.authorProfileIcon,
|
||||
Text(
|
||||
'${calculateDifference(post.createdAt)} в ${post.createdAt.hour.toString().padLeft(2, '0')}:${post.createdAt.minute.toString().padLeft(2, '0')}',
|
||||
style: const TextStyle(
|
||||
height: 1,
|
||||
color: Colors.black45,
|
||||
),
|
||||
backgroundImage: const AssetImage(
|
||||
'assets/images/no_avatar.png',
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
post.author.authorName,
|
||||
style: const TextStyle(
|
||||
height: 1,
|
||||
color: Colors.black87,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'${calculateDifference(post.createdAt)} в ${post.createdAt.hour.toString().padLeft(2, '0')}:${post.createdAt.minute.toString().padLeft(2, '0')}',
|
||||
style: const TextStyle(
|
||||
height: 1,
|
||||
color: Colors.black45,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const Spacer(),
|
||||
CustomPopup(
|
||||
isAuthor: post.author.authorIsMe,
|
||||
isAuthorAdmin: post.author.isAdmin || state.profile.roles.contains('admin'),
|
||||
complainedContentType: ComplainedContent.posts,
|
||||
path: 'blog_post/${post.id}',
|
||||
id: post.id,
|
||||
@@ -166,33 +144,34 @@ class _ProfileBlogPostWidgetState extends State<ProfileBlogPostWidget> {
|
||||
),
|
||||
),
|
||||
),
|
||||
if (post.title.isNotEmpty || post.description.isNotEmpty)
|
||||
if (hasMedia && (post.title.isNotEmpty || post.description.isNotEmpty))
|
||||
const SizedBox(height: 8),
|
||||
BlogPostMediaContent(
|
||||
galleryImages: post.images,
|
||||
imageUrl: imageUrl,
|
||||
videoEmbedUrl: embedUrl != null && embedUrl.isNotEmpty
|
||||
? embedUrl
|
||||
: null,
|
||||
videoUrlThumb: post.videoUrlThumb != null
|
||||
? PostFeedMapper.resolveMediaUrl(post.videoUrlThumb)
|
||||
: null,
|
||||
currentGalleryIndex: currentGalleryIndex,
|
||||
heroTag: 'profile-blog-post-${post.id}-cover',
|
||||
onOpenGallery: (urls, index) => _openGallery(
|
||||
urls,
|
||||
index,
|
||||
heroTag: urls.length == 1
|
||||
? 'profile-blog-post-${post.id}-cover'
|
||||
if (hasMedia)
|
||||
BlogPostMediaContent(
|
||||
galleryImages: post.images,
|
||||
imageUrl: imageUrl,
|
||||
videoEmbedUrl: embedUrl != null && embedUrl.isNotEmpty
|
||||
? embedUrl
|
||||
: null,
|
||||
videoUrlThumb: post.videoUrlThumb != null
|
||||
? PostFeedMapper.resolveMediaUrl(post.videoUrlThumb)
|
||||
: null,
|
||||
currentGalleryIndex: currentGalleryIndex,
|
||||
heroTag: 'profile-blog-post-${post.id}-cover',
|
||||
onOpenGallery: (urls, index) => _openGallery(
|
||||
urls,
|
||||
index,
|
||||
heroTag: urls.length == 1
|
||||
? 'profile-blog-post-${post.id}-cover'
|
||||
: null,
|
||||
),
|
||||
onGalleryChanged: (index) {
|
||||
setState(() {
|
||||
currentGalleryIndex = index;
|
||||
});
|
||||
},
|
||||
),
|
||||
onGalleryChanged: (index) {
|
||||
setState(() {
|
||||
currentGalleryIndex = index;
|
||||
});
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
SizedBox(height: hasMedia ? 12 : 8),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
child: Row(
|
||||
|
||||
@@ -223,6 +223,7 @@ class _AboutRecipeScreenState extends State<AboutRecipeScreen> {
|
||||
actions: [
|
||||
CustomPopup(
|
||||
isAuthor: recipeState.recipe.author.authorIsMe,
|
||||
isAuthorAdmin: recipeState.recipe.author.isAdmin,
|
||||
path: 'recipe/${widget.recipeSlug}',
|
||||
complainedContentType: ComplainedContent.recipe,
|
||||
slug: widget.recipeSlug,
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,7 +20,8 @@ import 'package:flutter_foreground_task/flutter_foreground_task.dart';
|
||||
import 'package:livekit_client/livekit_client.dart' as lk;
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
|
||||
typedef VideoCallManagerListener = void Function(VideoCallManagerSnapshot snapshot);
|
||||
typedef VideoCallManagerListener =
|
||||
void Function(VideoCallManagerSnapshot snapshot);
|
||||
|
||||
class VideoCallManager {
|
||||
VideoCallManager({
|
||||
@@ -158,9 +159,7 @@ class VideoCallManager {
|
||||
return lk.CameraCaptureOptions(
|
||||
cameraPosition:
|
||||
cameraPosition ??
|
||||
(_useFrontCamera
|
||||
? lk.CameraPosition.front
|
||||
: lk.CameraPosition.back),
|
||||
(_useFrontCamera ? lk.CameraPosition.front : lk.CameraPosition.back),
|
||||
params: lk.VideoParametersPresets.h1080_169,
|
||||
);
|
||||
}
|
||||
@@ -286,10 +285,11 @@ class VideoCallManager {
|
||||
if (_liveKitDisposed) return false;
|
||||
|
||||
_callStageEntered = true;
|
||||
await _startCallAudioSession();
|
||||
if (_liveKitDisposed || !_callStageEntered) return false;
|
||||
await _publishLocalMedia();
|
||||
if (_liveKitDisposed || !_callStageEntered) return false;
|
||||
await _startForegroundIfPossible();
|
||||
await _startCallAudioSession();
|
||||
return true;
|
||||
} catch (e) {
|
||||
_emitSnapshot(
|
||||
@@ -352,8 +352,7 @@ class VideoCallManager {
|
||||
return;
|
||||
}
|
||||
final room = _room;
|
||||
if (room == null ||
|
||||
room.connectionState != lk.ConnectionState.connected) {
|
||||
if (room == null || room.connectionState != lk.ConnectionState.connected) {
|
||||
return;
|
||||
}
|
||||
if (room.remoteParticipants.isEmpty) return;
|
||||
@@ -469,13 +468,20 @@ class VideoCallManager {
|
||||
if (next) {
|
||||
final granted = await _ensureMicrophonePermission();
|
||||
if (!granted) return;
|
||||
await _startCallAudioSession();
|
||||
}
|
||||
final participant = _room!.localParticipant;
|
||||
if (participant == null) return;
|
||||
try {
|
||||
await participant.setMicrophoneEnabled(
|
||||
next,
|
||||
audioCaptureOptions: _micCaptureOptions,
|
||||
);
|
||||
} catch (_) {
|
||||
return;
|
||||
}
|
||||
_emitSnapshot(_snapshot.copyWith(micEnabled: next));
|
||||
_lastMutedMicTotalEnergy = null;
|
||||
await _room!.localParticipant?.setMicrophoneEnabled(
|
||||
next,
|
||||
audioCaptureOptions: _micCaptureOptions,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> toggleVideo(bool currentEnabled) async {
|
||||
@@ -536,9 +542,7 @@ class VideoCallManager {
|
||||
try {
|
||||
await lp.setCameraEnabled(
|
||||
true,
|
||||
cameraCaptureOptions: cameraCaptureOptions(
|
||||
cameraPosition: newPosition,
|
||||
),
|
||||
cameraCaptureOptions: cameraCaptureOptions(cameraPosition: newPosition),
|
||||
);
|
||||
} catch (_) {}
|
||||
}
|
||||
@@ -663,6 +667,15 @@ class VideoCallManager {
|
||||
|
||||
Future<void> _onAppResumed() async {
|
||||
if (!isActiveCall) return;
|
||||
await _startCallAudioSession();
|
||||
if (_snapshot.micEnabled) {
|
||||
try {
|
||||
await _room?.localParticipant?.setMicrophoneEnabled(
|
||||
true,
|
||||
audioCaptureOptions: _micCaptureOptions,
|
||||
);
|
||||
} catch (_) {}
|
||||
}
|
||||
await _resumeVideoAfterForeground();
|
||||
}
|
||||
|
||||
@@ -670,12 +683,10 @@ class VideoCallManager {
|
||||
if (!isActiveCall) return;
|
||||
final ctx = _navigatorContext;
|
||||
final s = ctx != null ? S.of(ctx) : null;
|
||||
final title =
|
||||
resolvedRemoteDisplayName(
|
||||
s?.video_call_counterpart_default ?? 'Call',
|
||||
);
|
||||
final body =
|
||||
s?.video_call_notification_in_progress ?? 'Call in progress';
|
||||
final title = resolvedRemoteDisplayName(
|
||||
s?.video_call_counterpart_default ?? 'Call',
|
||||
);
|
||||
final body = s?.video_call_notification_in_progress ?? 'Call in progress';
|
||||
final endLabel = s?.video_call_notification_end ?? 'End call';
|
||||
await _foreground.startOngoingCall(
|
||||
conversationUuid: _snapshot.conversationUuid,
|
||||
@@ -739,11 +750,7 @@ class VideoCallManager {
|
||||
_roomConnectFuture = null;
|
||||
_clearPreparedConnectionCredentials();
|
||||
_callAcceptNotifiedConversationUuids.clear();
|
||||
_emitSnapshot(
|
||||
_snapshot.copyWith(
|
||||
counterpartPresentInLobby: false,
|
||||
),
|
||||
);
|
||||
_emitSnapshot(_snapshot.copyWith(counterpartPresentInLobby: false));
|
||||
}
|
||||
|
||||
void _clearPreparedConnectionCredentials() {
|
||||
@@ -925,10 +932,13 @@ class VideoCallManager {
|
||||
_preparedWsUrl = tokenResponse.wsUrl;
|
||||
_preparedToken = tokenResponse.token;
|
||||
|
||||
await room.prepareConnection(
|
||||
tokenResponse.wsUrl,
|
||||
tokenResponse.token,
|
||||
);
|
||||
// Warmup must not keep the enter button disabled. Joining the room
|
||||
// connects with the token even if prepareConnection hangs or fails.
|
||||
try {
|
||||
await room
|
||||
.prepareConnection(tokenResponse.wsUrl, tokenResponse.token)
|
||||
.timeout(const Duration(seconds: 5));
|
||||
} catch (_) {}
|
||||
if (_liveKitDisposed) return;
|
||||
|
||||
_emitSnapshot(
|
||||
@@ -977,8 +987,7 @@ class VideoCallManager {
|
||||
_syncRemoteParticipantNameFromRoom();
|
||||
|
||||
_syncPoorConnectionOverlay(
|
||||
room.localParticipant?.connectionQuality ??
|
||||
lk.ConnectionQuality.unknown,
|
||||
room.localParticipant?.connectionQuality ?? lk.ConnectionQuality.unknown,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1002,8 +1011,6 @@ class VideoCallManager {
|
||||
status.isPermanentlyDenied || status.isRestricted;
|
||||
|
||||
Future<bool> _ensureCameraPermission() async {
|
||||
final ctx = _navigatorContext;
|
||||
if (ctx == null) return false;
|
||||
var status = await Permission.camera.status;
|
||||
if (status.isGranted) return true;
|
||||
status = await Permission.camera.request();
|
||||
@@ -1023,8 +1030,6 @@ class VideoCallManager {
|
||||
}
|
||||
|
||||
Future<bool> _ensureMicrophonePermission() async {
|
||||
final ctx = _navigatorContext;
|
||||
if (ctx == null) return false;
|
||||
var status = await Permission.microphone.status;
|
||||
if (status.isGranted) return true;
|
||||
status = await Permission.microphone.request();
|
||||
@@ -1136,4 +1141,4 @@ class VideoCallManager {
|
||||
await _audioSession.stop();
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,10 +66,9 @@ class VideoCallSessionCubit extends BaseCubit<VideoCallSessionState> {
|
||||
connectPhase: snapshot.connectPhase,
|
||||
linkIssue: snapshot.linkIssue,
|
||||
callStageEntered: resetCallStage ? false : state.callStageEntered,
|
||||
uiMode:
|
||||
_isCallSessionInactive(snapshot)
|
||||
? VideoCallUiMode.hidden
|
||||
: state.uiMode,
|
||||
uiMode: _isCallSessionInactive(snapshot)
|
||||
? VideoCallUiMode.hidden
|
||||
: state.uiMode,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -364,8 +363,6 @@ class VideoCallSessionCubit extends BaseCubit<VideoCallSessionState> {
|
||||
if (!granted || isClosed) return;
|
||||
|
||||
await _videoCallManager.toggleMic(state.isMicActiveForUi);
|
||||
if (isClosed) return;
|
||||
emit(state.copyWith(micEnabled: true));
|
||||
}
|
||||
|
||||
@visibleForTesting
|
||||
|
||||
@@ -1,43 +1,65 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:audio_session/audio_session.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
typedef VideoCallAudioSessionFactory = Future<AudioSession> Function();
|
||||
typedef IosCallAudioSessionHandler = Future<void> Function(String method);
|
||||
|
||||
/// Configures and owns the platform audio session for an active VoIP call.
|
||||
class VideoCallAudioSessionCoordinator {
|
||||
VideoCallAudioSessionCoordinator({
|
||||
VideoCallAudioSessionFactory? audioSessionFactory,
|
||||
bool? isIos,
|
||||
IosCallAudioSessionHandler? iosCallAudioSession,
|
||||
}) : _audioSessionFactory =
|
||||
audioSessionFactory ?? (() => AudioSession.instance);
|
||||
audioSessionFactory ?? (() => AudioSession.instance),
|
||||
_isIos = isIos ?? Platform.isIOS,
|
||||
_iosCallAudioSession = iosCallAudioSession ?? _invokeIosCallAudioSession;
|
||||
|
||||
static const MethodChannel _iosChannel = MethodChannel(
|
||||
'com.eatvioapp/call_audio_session',
|
||||
);
|
||||
|
||||
final VideoCallAudioSessionFactory _audioSessionFactory;
|
||||
final bool _isIos;
|
||||
final IosCallAudioSessionHandler _iosCallAudioSession;
|
||||
|
||||
static final AudioSessionConfiguration callConfiguration =
|
||||
AudioSessionConfiguration(
|
||||
avAudioSessionCategory: AVAudioSessionCategory.playAndRecord,
|
||||
avAudioSessionCategoryOptions:
|
||||
AVAudioSessionCategoryOptions.allowBluetooth |
|
||||
avAudioSessionCategory: AVAudioSessionCategory.playAndRecord,
|
||||
avAudioSessionCategoryOptions:
|
||||
AVAudioSessionCategoryOptions.allowBluetooth |
|
||||
AVAudioSessionCategoryOptions.defaultToSpeaker,
|
||||
avAudioSessionMode: AVAudioSessionMode.voiceChat,
|
||||
avAudioSessionRouteSharingPolicy:
|
||||
AVAudioSessionRouteSharingPolicy.defaultPolicy,
|
||||
avAudioSessionSetActiveOptions: AVAudioSessionSetActiveOptions.none,
|
||||
androidAudioAttributes: const AndroidAudioAttributes(
|
||||
contentType: AndroidAudioContentType.speech,
|
||||
flags: AndroidAudioFlags.none,
|
||||
usage: AndroidAudioUsage.voiceCommunication,
|
||||
),
|
||||
androidAudioFocusGainType: AndroidAudioFocusGainType.gain,
|
||||
androidWillPauseWhenDucked: true,
|
||||
);
|
||||
avAudioSessionMode: AVAudioSessionMode.voiceChat,
|
||||
avAudioSessionRouteSharingPolicy:
|
||||
AVAudioSessionRouteSharingPolicy.defaultPolicy,
|
||||
avAudioSessionSetActiveOptions: AVAudioSessionSetActiveOptions.none,
|
||||
androidAudioAttributes: const AndroidAudioAttributes(
|
||||
contentType: AndroidAudioContentType.speech,
|
||||
flags: AndroidAudioFlags.none,
|
||||
usage: AndroidAudioUsage.voiceCommunication,
|
||||
),
|
||||
androidAudioFocusGainType: AndroidAudioFocusGainType.gain,
|
||||
androidWillPauseWhenDucked: true,
|
||||
);
|
||||
|
||||
AudioSession? _session;
|
||||
bool _configured = false;
|
||||
bool _iosActive = false;
|
||||
|
||||
bool get isActive => _session != null;
|
||||
bool get isActive => _isIos ? _iosActive : _session != null;
|
||||
|
||||
static Future<void> _invokeIosCallAudioSession(String method) {
|
||||
return _iosChannel.invokeMethod<void>(method);
|
||||
}
|
||||
|
||||
Future<void> start() async {
|
||||
if (_isIos) {
|
||||
await _iosCallAudioSession('activate');
|
||||
_iosActive = true;
|
||||
return;
|
||||
}
|
||||
final session = await _audioSessionFactory();
|
||||
_session = session;
|
||||
if (!_configured) {
|
||||
@@ -48,6 +70,15 @@ class VideoCallAudioSessionCoordinator {
|
||||
}
|
||||
|
||||
Future<bool> reactivate() async {
|
||||
if (_isIos) {
|
||||
try {
|
||||
await _iosCallAudioSession('activate');
|
||||
_iosActive = true;
|
||||
return true;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
final session = _session;
|
||||
if (session == null) return false;
|
||||
try {
|
||||
@@ -58,6 +89,13 @@ class VideoCallAudioSessionCoordinator {
|
||||
}
|
||||
|
||||
Future<void> stop() async {
|
||||
if (_isIos) {
|
||||
_iosActive = false;
|
||||
try {
|
||||
await _iosCallAudioSession('deactivate');
|
||||
} catch (_) {}
|
||||
return;
|
||||
}
|
||||
final session = _session;
|
||||
_session = null;
|
||||
if (session == null) return;
|
||||
|
||||
@@ -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());
|
||||
});
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import 'package:eatvio_app/features/chat/models/chat_message_model.dart';
|
||||
import 'package:eatvio_app/features/video_calls/services/video_call_push_payload.dart';
|
||||
|
||||
enum VideoCallChatSignalKind { none, started, ended }
|
||||
|
||||
/// Detects a video-call system message.
|
||||
///
|
||||
/// The realtime `ChatMessageUpdated` payload does not include `type`, so an
|
||||
/// open app has to recognize the system text. Messages that already have
|
||||
/// another type are ignored.
|
||||
abstract final class VideoCallChatSignal {
|
||||
static final RegExp _startedRu = RegExp(
|
||||
r'начал\(а\) видеозвонок\.?\s*$',
|
||||
caseSensitive: false,
|
||||
);
|
||||
static final RegExp _startedEn = RegExp(
|
||||
r'started a video call\.?\s*$',
|
||||
caseSensitive: false,
|
||||
);
|
||||
static final RegExp _endedRu = RegExp(
|
||||
r'завершил\(а\) видеозвонок\.?\s*$',
|
||||
caseSensitive: false,
|
||||
);
|
||||
static final RegExp _endedEn = RegExp(
|
||||
r'ended a video call\.?\s*$',
|
||||
caseSensitive: false,
|
||||
);
|
||||
static final RegExp _callerRu = RegExp(
|
||||
r'^(.*) начал\(а\) видеозвонок\.?\s*$',
|
||||
caseSensitive: false,
|
||||
);
|
||||
static final RegExp _callerEn = RegExp(
|
||||
r'^(.*) started a video call\.?\s*$',
|
||||
caseSensitive: false,
|
||||
);
|
||||
|
||||
static VideoCallChatSignalKind kindOf(ChatMessageModel message) {
|
||||
if (message.type == ChatMessageType.videoCallEnded) {
|
||||
return VideoCallChatSignalKind.ended;
|
||||
}
|
||||
if (message.type == ChatMessageType.videoCallStarted) {
|
||||
return VideoCallChatSignalKind.started;
|
||||
}
|
||||
if (message.type != null) return VideoCallChatSignalKind.none;
|
||||
|
||||
final text = message.data.trim();
|
||||
if (_endedRu.hasMatch(text) || _endedEn.hasMatch(text)) {
|
||||
return VideoCallChatSignalKind.ended;
|
||||
}
|
||||
if (_startedRu.hasMatch(text) || _startedEn.hasMatch(text)) {
|
||||
return VideoCallChatSignalKind.started;
|
||||
}
|
||||
return VideoCallChatSignalKind.none;
|
||||
}
|
||||
|
||||
static VideoCallPushPayload? incomingPayload({
|
||||
required ChatMessageModel message,
|
||||
String? callerName,
|
||||
String? callerAvatarUrl,
|
||||
}) {
|
||||
if (kindOf(message) != VideoCallChatSignalKind.started) return null;
|
||||
final conversationUuid = message.roomId?.trim();
|
||||
if (conversationUuid == null || conversationUuid.isEmpty) return null;
|
||||
|
||||
final resolvedName = callerName?.trim();
|
||||
final avatar = callerAvatarUrl?.trim();
|
||||
return VideoCallPushPayload(
|
||||
conversationUuid: conversationUuid,
|
||||
counterpartName: resolvedName != null && resolvedName.isNotEmpty
|
||||
? resolvedName
|
||||
: callerNameFromBody(message.data),
|
||||
counterpartAvatarUrl: avatar != null && avatar.isNotEmpty ? avatar : null,
|
||||
messageId: message.id > 0 ? message.id : null,
|
||||
);
|
||||
}
|
||||
|
||||
static String? callerNameFromBody(String body) {
|
||||
final text = body.trim();
|
||||
for (final pattern in [_callerRu, _callerEn]) {
|
||||
final name = pattern.firstMatch(text)?.group(1)?.trim();
|
||||
if (name != null && name.isNotEmpty) return name;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,6 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:eatvio_app/features/video_calls/services/video_call_kit_id.dart';
|
||||
import 'package:eatvio_app/features/video_calls/services/video_call_push_payload.dart';
|
||||
import 'package:flutter_callkit_incoming/entities/entities.dart';
|
||||
import 'package:flutter_callkit_incoming/flutter_callkit_incoming.dart';
|
||||
@@ -6,18 +9,25 @@ abstract final class VideoCallIncomingPushPresenter {
|
||||
static const String _androidAccentColor = '#2787F5';
|
||||
|
||||
static Future<void> showIncomingCall(VideoCallPushPayload payload) async {
|
||||
final callerName =
|
||||
payload.counterpartName?.trim().isNotEmpty == true
|
||||
? payload.counterpartName!.trim()
|
||||
: 'Итвио';
|
||||
final callerName = payload.counterpartName?.trim().isNotEmpty == true
|
||||
? payload.counterpartName!.trim()
|
||||
: 'Итвио';
|
||||
|
||||
final callId = Platform.isIOS
|
||||
? VideoCallKitId.iosUuid(
|
||||
conversationUuid: payload.conversationUuid,
|
||||
messageId: payload.messageId,
|
||||
) ??
|
||||
payload.conversationUuid
|
||||
: payload.conversationUuid;
|
||||
|
||||
final params = CallKitParams(
|
||||
id: payload.conversationUuid,
|
||||
id: callId,
|
||||
nameCaller: callerName,
|
||||
appName: 'Итвио',
|
||||
avatar: payload.counterpartAvatarUrl,
|
||||
handle: payload.conversationUuid,
|
||||
type: 0,
|
||||
type: 1,
|
||||
duration: 45000,
|
||||
extra: <String, dynamic>{
|
||||
'conversationUuid': payload.conversationUuid,
|
||||
|
||||
23
lib/features/video_calls/services/video_call_kit_id.dart
Normal file
23
lib/features/video_calls/services/video_call_kit_id.dart
Normal file
@@ -0,0 +1,23 @@
|
||||
/// CallKit on iOS rejects a second incoming call that reuses a UUID.
|
||||
///
|
||||
/// The conversation uuid stays in the call payload for navigation. The CallKit
|
||||
/// id is derived from that uuid plus the chat message id, so one ring is one
|
||||
/// call and the next ring in the same chat still appears. Android notifications
|
||||
/// keep the conversation uuid: the same id updates one incoming notification.
|
||||
abstract final class VideoCallKitId {
|
||||
static final RegExp _uuidPrefix = RegExp(
|
||||
r'^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-)[0-9a-fA-F]{12}$',
|
||||
);
|
||||
|
||||
static String? iosUuid({
|
||||
required String conversationUuid,
|
||||
required int? messageId,
|
||||
}) {
|
||||
if (messageId == null || messageId <= 0) return null;
|
||||
final prefix = _uuidPrefix.firstMatch(conversationUuid.trim())?.group(1);
|
||||
if (prefix == null) return null;
|
||||
final suffix = messageId.toRadixString(16).padLeft(12, '0');
|
||||
if (suffix.length != 12) return null;
|
||||
return '$prefix$suffix'.toLowerCase();
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ class VideoCallPushPayload {
|
||||
this.counterpartAvatarUrl,
|
||||
this.localParticipantName,
|
||||
this.counterpartIsConsultant,
|
||||
this.messageId,
|
||||
});
|
||||
|
||||
final String conversationUuid;
|
||||
@@ -12,6 +13,7 @@ class VideoCallPushPayload {
|
||||
final String? counterpartAvatarUrl;
|
||||
final String? localParticipantName;
|
||||
final bool? counterpartIsConsultant;
|
||||
final int? messageId;
|
||||
|
||||
static VideoCallPushPayload? tryParse(Map<String, dynamic> data) {
|
||||
if (!isIncomingVideoCall(data)) return null;
|
||||
@@ -49,6 +51,7 @@ class VideoCallPushPayload {
|
||||
'is_consultant',
|
||||
'isConsultant',
|
||||
]),
|
||||
messageId: _messageId(data),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -132,7 +135,8 @@ class VideoCallPushPayload {
|
||||
final uri = Uri.tryParse(actionUrl);
|
||||
if (uri == null) return null;
|
||||
|
||||
final fromQuery = uri.queryParameters['uuid'] ??
|
||||
final fromQuery =
|
||||
uri.queryParameters['uuid'] ??
|
||||
uri.queryParameters['conversationUuid'] ??
|
||||
uri.queryParameters['conversation_uuid'];
|
||||
if (fromQuery != null && fromQuery.trim().isNotEmpty) {
|
||||
@@ -167,14 +171,22 @@ class VideoCallPushPayload {
|
||||
return null;
|
||||
}
|
||||
|
||||
static int? _messageId(Map<String, dynamic> data) {
|
||||
final value = data['message_id'] ?? data['messageId'];
|
||||
if (value is int) return value > 0 ? value : null;
|
||||
if (value is String) {
|
||||
final parsed = int.tryParse(value.trim());
|
||||
if (parsed != null && parsed > 0) return parsed;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static bool _isTruthy(Object? value) {
|
||||
if (value == null) return false;
|
||||
if (value is bool) return value;
|
||||
if (value is num) return value != 0;
|
||||
final normalized = value.toString().trim().toLowerCase();
|
||||
return normalized == '1' ||
|
||||
normalized == 'true' ||
|
||||
normalized == 'yes';
|
||||
return normalized == '1' || normalized == 'true' || normalized == 'yes';
|
||||
}
|
||||
|
||||
static String? _resolveAvatarUrl(String? raw) {
|
||||
|
||||
@@ -44,82 +44,112 @@ class _WelcomeScreenState extends State<WelcomeScreen> {
|
||||
),
|
||||
Positioned.fill(
|
||||
child: SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(bottom: 16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
const SizedBox(height: 80),
|
||||
SvgPicture.asset('assets/images/logo_welcome.svg'),
|
||||
const Spacer(),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Material(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
color: Colors.white,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const SizedBox(height: 16),
|
||||
const Text(
|
||||
'Добро пожаловать!',
|
||||
style: TextStyle(
|
||||
fontSize: 28,
|
||||
fontWeight: FontWeight.w800,
|
||||
height: 1,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
const Text(
|
||||
'Это первая версия приложения,\nи мы активно работаем над его\nулучшением. Если вы заметите ошибку\nили у вас есть идея — пожалуйста,\nсообщите нам!\n\nВаша помощь бесценна',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
height: 1.15,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
InkWell(
|
||||
key: AppKeys.welcomeScreenButton,
|
||||
onTap: () async {
|
||||
final sharedPreferences = sl<Prefs>();
|
||||
await sharedPreferences.setBool(PrefsKeys.welcomeScreenWasShowed, true);
|
||||
if (await TokenStorage.haveTokenInStorage()) {
|
||||
goRouter.go('/feed-screen');
|
||||
} else {
|
||||
goRouter.go('/auth-screen');
|
||||
}
|
||||
},
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Ink(
|
||||
height: 52,
|
||||
decoration: BoxDecoration(
|
||||
color: appTheme.blueA400,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: const Center(
|
||||
child: Text(
|
||||
'Начать пользоваться',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
height: 1,
|
||||
color: Colors.white,
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final textScale = MediaQuery.textScalerOf(context).scale(1);
|
||||
final topGap = textScale > 1.2 ? 24.0 : 80.0;
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.only(bottom: 16),
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
minHeight: constraints.maxHeight - 16,
|
||||
),
|
||||
child: IntrinsicHeight(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
SizedBox(height: topGap),
|
||||
SvgPicture.asset('assets/images/logo_welcome.svg'),
|
||||
const Spacer(),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Material(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
color: Colors.white,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment:
|
||||
CrossAxisAlignment.start,
|
||||
children: [
|
||||
const SizedBox(height: 16),
|
||||
const Text(
|
||||
'Добро пожаловать!',
|
||||
style: TextStyle(
|
||||
fontSize: 28,
|
||||
fontWeight: FontWeight.w800,
|
||||
height: 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
const Text(
|
||||
'Это первая версия приложения,\nи мы активно работаем над его\nулучшением. Если вы заметите ошибку\nили у вас есть идея — пожалуйста,\nсообщите нам!\n\nВаша помощь бесценна',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
height: 1.15,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
InkWell(
|
||||
key: AppKeys.welcomeScreenButton,
|
||||
onTap: () async {
|
||||
final sharedPreferences = sl<Prefs>();
|
||||
await sharedPreferences.setBool(
|
||||
PrefsKeys.welcomeScreenWasShowed,
|
||||
true,
|
||||
);
|
||||
if (await TokenStorage.haveTokenInStorage()) {
|
||||
goRouter.go('/feed-screen');
|
||||
} else {
|
||||
goRouter.go('/auth-screen');
|
||||
}
|
||||
},
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Ink(
|
||||
decoration: BoxDecoration(
|
||||
color: appTheme.blueA400,
|
||||
borderRadius: BorderRadius.circular(
|
||||
8,
|
||||
),
|
||||
),
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(
|
||||
minHeight: 52,
|
||||
),
|
||||
child: const Padding(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 14,
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
'Начать пользоваться',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
height: 1,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -137,6 +137,9 @@ class MessageLookup extends MessageLookupByLibrary {
|
||||
"Средние показатели давления:",
|
||||
),
|
||||
"back_to_chat": MessageLookupByLibrary.simpleMessage("Вернуться в чат"),
|
||||
"blog_post_image_min_size_error": MessageLookupByLibrary.simpleMessage(
|
||||
"Минимальный размер изображения — 500×500 px",
|
||||
),
|
||||
"bodymass_chart": MessageLookupByLibrary.simpleMessage("График ИМТ"),
|
||||
"by_default": MessageLookupByLibrary.simpleMessage("По умолчанию"),
|
||||
"calls_for_bullying": MessageLookupByLibrary.simpleMessage(
|
||||
|
||||
@@ -55,6 +55,9 @@ class MessageLookup extends MessageLookupByLibrary {
|
||||
"average_pressure": MessageLookupByLibrary.simpleMessage(
|
||||
"Средние показатели давления:",
|
||||
),
|
||||
"blog_post_image_min_size_error": MessageLookupByLibrary.simpleMessage(
|
||||
"Минимальный размер изображения — 500×500 px",
|
||||
),
|
||||
"bodymass_chart": MessageLookupByLibrary.simpleMessage("График ИМТ"),
|
||||
"by_default": MessageLookupByLibrary.simpleMessage("По умолчанию"),
|
||||
"calories": MessageLookupByLibrary.simpleMessage("Калории"),
|
||||
|
||||
@@ -2569,6 +2569,16 @@ class S {
|
||||
);
|
||||
}
|
||||
|
||||
/// `Минимальный размер изображения — 500×500 px`
|
||||
String get blog_post_image_min_size_error {
|
||||
return Intl.message(
|
||||
'Минимальный размер изображения — 500×500 px',
|
||||
name: 'blog_post_image_min_size_error',
|
||||
desc: '',
|
||||
args: [],
|
||||
);
|
||||
}
|
||||
|
||||
/// `Информация о заказе`
|
||||
String get infoAboutOrder {
|
||||
return Intl.message(
|
||||
|
||||
@@ -317,6 +317,7 @@
|
||||
"could_not_publish_blog_post": "Не удалось опубликовать пост",
|
||||
"could_not_update_blog_post": "Не удалось обновить пост",
|
||||
"could_not_delete_blog_post": "Не удалось удалить пост",
|
||||
"blog_post_image_min_size_error": "Минимальный размер изображения — 500×500 px",
|
||||
"infoAboutOrder": "Информация о заказе",
|
||||
"to_order": "Заказать",
|
||||
"can_not_choose_start_date": "Дату старта плана невозможно выбрать, пока Вы не привяжите карту",
|
||||
|
||||
@@ -270,5 +270,6 @@
|
||||
"video_call_close": "Закрыть",
|
||||
"video_call_notification_in_progress": "Звонок идёт",
|
||||
"video_call_notification_return": "Вернуться к звонку",
|
||||
"video_call_notification_end": "Завершить звонок"
|
||||
"video_call_notification_end": "Завершить звонок",
|
||||
"blog_post_image_min_size_error": "Минимальный размер изображения — 500×500 px"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
name: eatvio_app
|
||||
description: A new Flutter project.
|
||||
version: 1.3.40+129
|
||||
version: 1.3.47+136
|
||||
publish_to: 'none'
|
||||
|
||||
environment:
|
||||
@@ -93,6 +93,10 @@ dependencies:
|
||||
wakelock_plus: ^1.5.2
|
||||
sbp: 0.1.0
|
||||
|
||||
# html 0.15.7 removed top-level query_selector.matches used by flutter_html 3.0.0.
|
||||
dependency_overrides:
|
||||
html: 0.15.6
|
||||
|
||||
dev_dependencies:
|
||||
build_runner: ^2.10.4
|
||||
freezed: ^3.2.0
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
36
test/core/utils/inline_svg_css_test.dart
Normal file
36
test/core/utils/inline_svg_css_test.dart
Normal file
@@ -0,0 +1,36 @@
|
||||
import 'package:eatvio_app/core/utils/inline_svg_css.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
group('inlineSvgCss', () {
|
||||
test(
|
||||
'inlines class fills from a style block so flutter_svg can paint colors',
|
||||
() {
|
||||
const svg = '''
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40">
|
||||
<style type="text/css">
|
||||
.st0{fill:#FFD200;}
|
||||
.st1{fill:#FFFFFF;}
|
||||
</style>
|
||||
<circle class="st0" cx="20" cy="20" r="18"/>
|
||||
<path class="st1" d="M10 20h20"/>
|
||||
</svg>
|
||||
''';
|
||||
|
||||
final result = inlineSvgCss(svg);
|
||||
|
||||
expect(result, isNot(contains('<style')));
|
||||
expect(result, contains('fill="#FFD200"'));
|
||||
expect(result, contains('fill="#FFFFFF"'));
|
||||
expect(result, isNot(contains('class="st0"')));
|
||||
},
|
||||
);
|
||||
|
||||
test('keeps SVG without style blocks unchanged', () {
|
||||
const svg =
|
||||
'<svg xmlns="http://www.w3.org/2000/svg"><circle fill="#00AA00" cx="10" cy="10" r="5"/></svg>';
|
||||
|
||||
expect(inlineSvgCss(svg), svg);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -252,5 +252,36 @@ void main() {
|
||||
|
||||
expect(manager.client, isNull);
|
||||
});
|
||||
|
||||
test('reconnected - эмитит сигнал после восстановления соединения', () async {
|
||||
final wsService = MockWsService();
|
||||
final manager = WsManager(wsService: wsService, errorReporter: const NoOpErrorReporter());
|
||||
|
||||
final received = <void>[];
|
||||
final subscription = manager.reconnected.listen(received.add);
|
||||
|
||||
manager.handleConnectionEstablished();
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
|
||||
expect(received, hasLength(1));
|
||||
|
||||
await subscription.cancel();
|
||||
});
|
||||
|
||||
test('reconnected - переживает clear (поток не закрывается при логауте)', () async {
|
||||
final wsService = MockWsService();
|
||||
final manager = WsManager(wsService: wsService, errorReporter: const NoOpErrorReporter());
|
||||
|
||||
final received = <void>[];
|
||||
final subscription = manager.reconnected.listen(received.add);
|
||||
|
||||
await manager.clear();
|
||||
manager.handleConnectionEstablished();
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
|
||||
expect(received, hasLength(1));
|
||||
|
||||
await subscription.cancel();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -120,6 +120,29 @@ void main() {
|
||||
expect(find.text('Заблокировать автора'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('CustomPopup for non-author admin hides complain but keeps block', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
wrap(
|
||||
const CustomPopup(
|
||||
isAuthor: false,
|
||||
isAuthorAdmin: true,
|
||||
complainedContentType: ComplainedContent.plan,
|
||||
path: 'plans/slug',
|
||||
slug: 'slug',
|
||||
userSlug: 'admin-user',
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.tap(find.byIcon(Icons.more_horiz_rounded));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Скопировать ссылку'), findsOneWidget);
|
||||
expect(find.text('Поделиться'), findsOneWidget);
|
||||
expect(find.text('Пожаловаться'), findsNothing);
|
||||
expect(find.text('Заблокировать автора'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('CustomPopup for author recipe does not show edit action', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
wrap(
|
||||
|
||||
@@ -128,6 +128,15 @@ void main() {
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('does not add extra space above the title field', (tester) async {
|
||||
await tester.pumpWidget(createTestWidget());
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final titleTop = tester.getTopLeft(find.text('Заголовок')).dy;
|
||||
final scrollTop = tester.getTopLeft(find.byType(SingleChildScrollView)).dy;
|
||||
expect(titleTop - scrollTop, 16);
|
||||
});
|
||||
|
||||
testWidgets('pre-fills form fields when editing existing blog post', (
|
||||
tester,
|
||||
) async {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -28,7 +28,9 @@ import 'chat_screen_loaded_smoke_test.mocks.dart';
|
||||
void main() {
|
||||
group('ChatScreen loaded smoke', () {
|
||||
setUpAll(() async {
|
||||
dotenv.loadFromString(envString: 'DEV_BASE_URL=http://localhost\nBASE_URL=http://localhost');
|
||||
dotenv.loadFromString(
|
||||
envString: 'DEV_BASE_URL=http://localhost\nBASE_URL=http://localhost',
|
||||
);
|
||||
await initializeDateFormatting('ru', null);
|
||||
await initializeDateFormatting('ru_RU', null);
|
||||
await initializeDateFormatting('RU', null);
|
||||
@@ -37,7 +39,11 @@ void main() {
|
||||
|
||||
setUp(() async {
|
||||
await sl.reset();
|
||||
sl.registerLazySingleton<AppConfig>(() => AppConfig(buildType: AppBuildType.tests)..setBaseUrl('http://localhost'));
|
||||
sl.registerLazySingleton<AppConfig>(
|
||||
() =>
|
||||
AppConfig(buildType: AppBuildType.tests)
|
||||
..setBaseUrl('http://localhost'),
|
||||
);
|
||||
sl.registerLazySingleton<WsManager>(() => _FakeWsManager());
|
||||
});
|
||||
|
||||
@@ -47,7 +53,9 @@ void main() {
|
||||
</svg>
|
||||
''';
|
||||
|
||||
final fakeBundle = _FakeAssetBundle({'assets/icons/error_icon.svg': fakeSvg});
|
||||
final fakeBundle = _FakeAssetBundle({
|
||||
'assets/icons/error_icon.svg': fakeSvg,
|
||||
});
|
||||
|
||||
Widget wrap(Widget child, ChatsCubit chatsCubit) {
|
||||
return DefaultAssetBundle(
|
||||
@@ -70,8 +78,18 @@ void main() {
|
||||
id: '1',
|
||||
chatRoomSettings: ChatRoomSettingsModel(name: 'room', state: 'active'),
|
||||
users: Users(
|
||||
sender: ChatUserModel(id: 1, name: 'Me', slug: 'me', profilePhotoUrl: null),
|
||||
receiver: ChatUserModel(id: 2, name: 'User Two', slug: 'user-two', profilePhotoUrl: null),
|
||||
sender: ChatUserModel(
|
||||
id: 1,
|
||||
name: 'Me',
|
||||
slug: 'me',
|
||||
profilePhotoUrl: null,
|
||||
),
|
||||
receiver: ChatUserModel(
|
||||
id: 2,
|
||||
name: 'User Two',
|
||||
slug: 'user-two',
|
||||
profilePhotoUrl: null,
|
||||
),
|
||||
),
|
||||
chosenPlanId: null,
|
||||
chosenPlan: null,
|
||||
@@ -147,12 +165,19 @@ void main() {
|
||||
);
|
||||
|
||||
when(chatRoomCubit.state).thenReturn(state);
|
||||
when(chatRoomCubit.stream).thenAnswer((_) => const Stream<ChatRoomState>.empty());
|
||||
when(
|
||||
chatRoomCubit.stream,
|
||||
).thenAnswer((_) => const Stream<ChatRoomState>.empty());
|
||||
when(chatRoomCubit.close()).thenAnswer((_) async => Future<void>.value());
|
||||
when(chatRoomCubit.initChat(any, sendInitialMessage: anyNamed('sendInitialMessage'), orderId: anyNamed('orderId')))
|
||||
.thenAnswer((_) async => 'uuid');
|
||||
when(
|
||||
chatRoomCubit.initChat(
|
||||
any,
|
||||
sendInitialMessage: anyNamed('sendInitialMessage'),
|
||||
orderId: anyNamed('orderId'),
|
||||
),
|
||||
).thenAnswer((_) async => 'uuid');
|
||||
when(chatRoomCubit.setTimer()).thenReturn(null);
|
||||
|
||||
when(chatRoomCubit.refreshChat()).thenAnswer((_) async {});
|
||||
|
||||
sl.registerFactory<ChatRoomCubit>(() => chatRoomCubit);
|
||||
|
||||
@@ -160,11 +185,17 @@ void main() {
|
||||
when(chatsCubit.stream).thenAnswer((_) => const Stream.empty());
|
||||
when(chatsCubit.close()).thenAnswer((_) async => Future<void>.value());
|
||||
|
||||
await tester.pumpWidget(wrap(const ChatScreen(uuid: 'uuid', sendInitialMessage: false), chatsCubit));
|
||||
await tester.pumpWidget(
|
||||
wrap(
|
||||
const ChatScreen(uuid: 'uuid', sendInitialMessage: false),
|
||||
chatsCubit,
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 200));
|
||||
|
||||
expect(find.byType(ChatScreen), findsOneWidget);
|
||||
expect(find.byType(RefreshIndicator), findsOneWidget);
|
||||
expect(find.text('User Two'), findsOneWidget);
|
||||
expect(find.text('hello'), findsOneWidget);
|
||||
expect(find.text('hi'), findsOneWidget);
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
|
||||
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
||||
import 'package:eatvio_app/features/chat/presentation/widgets/about_order_button.dart';
|
||||
import 'package:eatvio_app/features/chat/presentation/widgets/message_action_bottom_sheet.dart';
|
||||
@@ -8,6 +7,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_localizations/flutter_localizations.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
class _FakeAssetBundle extends CachingAssetBundle {
|
||||
_FakeAssetBundle(this.assets);
|
||||
@@ -66,7 +66,9 @@ void main() {
|
||||
|
||||
testWidgets('AboutOrderButton renders and taps', (tester) async {
|
||||
var tapped = false;
|
||||
await tester.pumpWidget(wrap(AboutOrderButton(onTap: () => tapped = true)));
|
||||
await tester.pumpWidget(
|
||||
wrap(AboutOrderButton(onTap: () => tapped = true)),
|
||||
);
|
||||
await tester.pump();
|
||||
expect(find.byType(AboutOrderButton), findsOneWidget);
|
||||
await tester.tap(find.byType(AboutOrderButton));
|
||||
@@ -75,24 +77,44 @@ void main() {
|
||||
});
|
||||
|
||||
testWidgets('MessageActionsBottomSheet renders', (tester) async {
|
||||
await tester.pumpWidget(wrap(MessageActionsBottomSheet(resend: () {}, delete: () {})));
|
||||
await tester.pumpWidget(
|
||||
wrap(MessageActionsBottomSheet(resend: () {}, delete: () {})),
|
||||
);
|
||||
await tester.pump();
|
||||
expect(find.byType(MessageActionsBottomSheet), findsOneWidget);
|
||||
expect(find.text(S.current.resend), findsOneWidget);
|
||||
expect(find.text(S.current.delete), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('NotificationBanner renders for multiple banner types', (tester) async {
|
||||
testWidgets('NotificationBanner renders for multiple banner types', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(
|
||||
wrap(
|
||||
const SingleChildScrollView(
|
||||
child: Column(
|
||||
children: [
|
||||
NotificationBanner(bannerType: BannerType.date, hasPersonalDiet: false),
|
||||
NotificationBanner(
|
||||
bannerType: BannerType.date,
|
||||
hasPersonalDiet: false,
|
||||
),
|
||||
NotificationBanner(bannerType: BannerType.card),
|
||||
NotificationBanner(bannerType: BannerType.collectCharge, planName: 'Plan', planPrice: 100, chosenPlanId: 1),
|
||||
NotificationBanner(bannerType: BannerType.finishPlan, planName: 'Plan', planPrice: 100, chosenPlanId: 1),
|
||||
NotificationBanner(bannerType: BannerType.setPersonalDiet, hasPersonalDiet: true),
|
||||
NotificationBanner(
|
||||
bannerType: BannerType.collectCharge,
|
||||
planName: 'Plan',
|
||||
planPrice: 100,
|
||||
chosenPlanId: 1,
|
||||
),
|
||||
NotificationBanner(
|
||||
bannerType: BannerType.finishPlan,
|
||||
planName: 'Plan',
|
||||
planPrice: 100,
|
||||
chosenPlanId: 1,
|
||||
),
|
||||
NotificationBanner(
|
||||
bannerType: BannerType.setPersonalDiet,
|
||||
hasPersonalDiet: true,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -101,5 +123,54 @@ void main() {
|
||||
await tester.pump();
|
||||
expect(find.byType(NotificationBanner), findsWidgets);
|
||||
});
|
||||
|
||||
testWidgets('collect charge banner opens card settings with chosenPlanId', (
|
||||
tester,
|
||||
) async {
|
||||
final router = GoRouter(
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: '/',
|
||||
builder: (context, state) => const Scaffold(
|
||||
body: NotificationBanner(
|
||||
bannerType: BannerType.collectCharge,
|
||||
planName: 'Plan',
|
||||
planPrice: 100,
|
||||
chosenPlanId: 17,
|
||||
),
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/card-settings-screen',
|
||||
builder: (context, state) =>
|
||||
Scaffold(body: Text('chosenPlanId:${state.extra}')),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
await tester.pumpWidget(
|
||||
DefaultAssetBundle(
|
||||
bundle: bundle,
|
||||
child: MaterialApp.router(
|
||||
theme: ThemeHelper().themeData(),
|
||||
localizationsDelegates: const [
|
||||
S.delegate,
|
||||
GlobalMaterialLocalizations.delegate,
|
||||
GlobalWidgetsLocalizations.delegate,
|
||||
GlobalCupertinoLocalizations.delegate,
|
||||
],
|
||||
supportedLocales: const [Locale('ru')],
|
||||
routerConfig: router,
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text('Получить выплату'), findsOneWidget);
|
||||
await tester.tap(find.text('Получить выплату'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('chosenPlanId:17'), findsOneWidget);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -16,12 +16,18 @@ import 'package:network_image_mock/network_image_mock.dart';
|
||||
void main() {
|
||||
group('ConsultationCardWidget smoke', () {
|
||||
setUpAll(() async {
|
||||
dotenv.loadFromString(envString: 'DEV_BASE_URL=http://localhost\nBASE_URL=http://localhost');
|
||||
dotenv.loadFromString(
|
||||
envString: 'DEV_BASE_URL=http://localhost\nBASE_URL=http://localhost',
|
||||
);
|
||||
});
|
||||
|
||||
setUp(() async {
|
||||
await sl.reset();
|
||||
sl.registerLazySingleton<AppConfig>(() => AppConfig(buildType: AppBuildType.tests)..setBaseUrl('http://localhost'));
|
||||
sl.registerLazySingleton<AppConfig>(
|
||||
() =>
|
||||
AppConfig(buildType: AppBuildType.tests)
|
||||
..setBaseUrl('http://localhost'),
|
||||
);
|
||||
});
|
||||
|
||||
final mockConsultation = ConsultationModel(
|
||||
@@ -39,9 +45,21 @@ void main() {
|
||||
endDate: DateTime(2025, 1, 8),
|
||||
consultantId: 1,
|
||||
diaries: [
|
||||
DiaryModel(id: 1, state: DiaryStateEnum.finished, date: DateTime(2025, 1, 1)),
|
||||
DiaryModel(id: 2, state: DiaryStateEnum.commented, date: DateTime(2025, 1, 2)),
|
||||
DiaryModel(id: 3, state: DiaryStateEnum.init, date: DateTime(2025, 1, 3)),
|
||||
DiaryModel(
|
||||
id: 1,
|
||||
state: DiaryStateEnum.finished,
|
||||
date: DateTime(2025, 1, 1),
|
||||
),
|
||||
DiaryModel(
|
||||
id: 2,
|
||||
state: DiaryStateEnum.commented,
|
||||
date: DateTime(2025, 1, 2),
|
||||
),
|
||||
DiaryModel(
|
||||
id: 3,
|
||||
state: DiaryStateEnum.init,
|
||||
date: DateTime(2025, 1, 3),
|
||||
),
|
||||
],
|
||||
personalDietId: null,
|
||||
);
|
||||
@@ -63,18 +81,25 @@ void main() {
|
||||
GoRoute(
|
||||
path: '/profile-screen',
|
||||
name: 'profileScreen',
|
||||
builder: (context, state) => const Scaffold(body: Text('Profile')),
|
||||
builder: (context, state) =>
|
||||
const Scaffold(body: Text('Profile')),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/user-details-screen',
|
||||
name: 'userDetailsScreen',
|
||||
builder: (context, state) => const Scaffold(body: Text('User Details')),
|
||||
builder: (context, state) =>
|
||||
const Scaffold(body: Text('User Details')),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/diary-screen',
|
||||
name: 'diaryScreen',
|
||||
builder: (context, state) => const Scaffold(body: Text('Diary')),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/card-settings-screen',
|
||||
builder: (context, state) =>
|
||||
Scaffold(body: Text('chosenPlanId:${state.extra}')),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -82,7 +107,9 @@ void main() {
|
||||
|
||||
testWidgets('renders consultation card with user info', (tester) async {
|
||||
await mockNetworkImagesFor(() async {
|
||||
await tester.pumpWidget(wrap(ConsultationCardWidget(consultationsModel: mockConsultation)));
|
||||
await tester.pumpWidget(
|
||||
wrap(ConsultationCardWidget(consultationsModel: mockConsultation)),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('John Doe'), findsOneWidget);
|
||||
@@ -92,12 +119,49 @@ void main() {
|
||||
|
||||
testWidgets('renders diary days', (tester) async {
|
||||
await mockNetworkImagesFor(() async {
|
||||
await tester.pumpWidget(wrap(ConsultationCardWidget(consultationsModel: mockConsultation)));
|
||||
await tester.pumpWidget(
|
||||
wrap(ConsultationCardWidget(consultationsModel: mockConsultation)),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
|
||||
expect(find.byType(InkWell), findsWidgets);
|
||||
});
|
||||
});
|
||||
|
||||
testWidgets('hides collect charge when consultation is in progress', (
|
||||
tester,
|
||||
) async {
|
||||
await mockNetworkImagesFor(() async {
|
||||
await tester.pumpWidget(
|
||||
wrap(ConsultationCardWidget(consultationsModel: mockConsultation)),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Получить выплату'), findsNothing);
|
||||
});
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'shows collect charge and opens card settings with chosen plan id when done',
|
||||
(tester) async {
|
||||
final doneConsultation = mockConsultation.copyWith(
|
||||
chosenPlanState: ChosenPlanStateEnum.stateDone,
|
||||
);
|
||||
|
||||
await mockNetworkImagesFor(() async {
|
||||
await tester.pumpWidget(
|
||||
wrap(ConsultationCardWidget(consultationsModel: doneConsultation)),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Получить выплату'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.text('Получить выплату'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('chosenPlanId:1'), findsOneWidget);
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -82,12 +82,65 @@ void main() {
|
||||
cubit.setSelectedOptionIndex(0);
|
||||
cubit.setPrice(1500.0);
|
||||
expect(cubit.state.planData.options[0].price, 1500.0);
|
||||
expect(cubit.state.validationState.optionsValidationState[0].priceHasError, false);
|
||||
expect(
|
||||
cubit.state.validationState.optionsValidationState[0].priceHasError,
|
||||
false,
|
||||
);
|
||||
|
||||
cubit.setPrice(1000.0);
|
||||
expect(cubit.state.validationState.optionsValidationState[0].priceHasError, true);
|
||||
expect(
|
||||
cubit.state.validationState.optionsValidationState[0].priceHasError,
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test(
|
||||
'option title, description, price and content stay independent per option',
|
||||
() {
|
||||
cubit.setSelectedOptionIndex(0);
|
||||
cubit.setOptionTitle('Economy');
|
||||
cubit.setPrice(1500.0);
|
||||
cubit.setOptionDescription('Economy description');
|
||||
cubit.setOptionContent('Economy item', 0);
|
||||
|
||||
cubit.setSelectedOptionIndex(1);
|
||||
cubit.setOptionTitle('Standard');
|
||||
cubit.setPrice(2500.0);
|
||||
cubit.setOptionDescription('Standard description');
|
||||
cubit.setOptionContent('Standard item', 0);
|
||||
|
||||
cubit.setSelectedOptionIndex(2);
|
||||
cubit.setOptionTitle('Comfort');
|
||||
cubit.setPrice(3500.0);
|
||||
cubit.setOptionDescription('Comfort description');
|
||||
cubit.setOptionContent('Comfort item', 0);
|
||||
|
||||
expect(cubit.state.planData.options[0].short, 'Economy');
|
||||
expect(cubit.state.planData.options[0].price, 1500.0);
|
||||
expect(
|
||||
cubit.state.planData.options[0].description,
|
||||
'Economy description',
|
||||
);
|
||||
expect(cubit.state.planData.options[0].items, ['Economy item']);
|
||||
|
||||
expect(cubit.state.planData.options[1].short, 'Standard');
|
||||
expect(cubit.state.planData.options[1].price, 2500.0);
|
||||
expect(
|
||||
cubit.state.planData.options[1].description,
|
||||
'Standard description',
|
||||
);
|
||||
expect(cubit.state.planData.options[1].items, ['Standard item']);
|
||||
|
||||
expect(cubit.state.planData.options[2].short, 'Comfort');
|
||||
expect(cubit.state.planData.options[2].price, 3500.0);
|
||||
expect(
|
||||
cubit.state.planData.options[2].description,
|
||||
'Comfort description',
|
||||
);
|
||||
expect(cubit.state.planData.options[2].items, ['Comfort item']);
|
||||
},
|
||||
);
|
||||
|
||||
test('setOptionDescription updates description and validates', () {
|
||||
cubit.setSelectedOptionIndex(0);
|
||||
cubit.setOptionDescription('Test description');
|
||||
@@ -129,11 +182,17 @@ void main() {
|
||||
test('validateSelectedOptionItems validates items', () {
|
||||
cubit.setSelectedOptionIndex(0);
|
||||
expect(cubit.validateSelectedOptionItems(), false);
|
||||
expect(cubit.state.validationState.optionsValidationState[0].isItemsEmpty, true);
|
||||
expect(
|
||||
cubit.state.validationState.optionsValidationState[0].isItemsEmpty,
|
||||
true,
|
||||
);
|
||||
|
||||
cubit.setOptionContent('Valid item', 0);
|
||||
expect(cubit.validateSelectedOptionItems(), true);
|
||||
expect(cubit.state.validationState.optionsValidationState[0].isItemsEmpty, false);
|
||||
expect(
|
||||
cubit.state.validationState.optionsValidationState[0].isItemsEmpty,
|
||||
false,
|
||||
);
|
||||
|
||||
cubit.setOptionContent('AB', 1);
|
||||
expect(cubit.validateSelectedOptionItems(), false);
|
||||
@@ -225,18 +284,27 @@ void main() {
|
||||
expect(cubit.state.validationState.imageHasError, false);
|
||||
});
|
||||
|
||||
test('validateSelectedOptionPrice returns false for price less than 1200', () {
|
||||
cubit.setSelectedOptionIndex(0);
|
||||
cubit.setPrice(1000.0);
|
||||
expect(cubit.validateSelectedOptionPrice(), false);
|
||||
expect(cubit.state.validationState.optionsValidationState[0].priceHasError, true);
|
||||
});
|
||||
test(
|
||||
'validateSelectedOptionPrice returns false for price less than 1200',
|
||||
() {
|
||||
cubit.setSelectedOptionIndex(0);
|
||||
cubit.setPrice(1000.0);
|
||||
expect(cubit.validateSelectedOptionPrice(), false);
|
||||
expect(
|
||||
cubit.state.validationState.optionsValidationState[0].priceHasError,
|
||||
true,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test('validateSelectedOptionPrice returns true for price >= 1200', () {
|
||||
cubit.setSelectedOptionIndex(0);
|
||||
cubit.setPrice(1200.0);
|
||||
expect(cubit.validateSelectedOptionPrice(), true);
|
||||
expect(cubit.state.validationState.optionsValidationState[0].priceHasError, false);
|
||||
expect(
|
||||
cubit.state.validationState.optionsValidationState[0].priceHasError,
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('setImage updates image and validates', () {
|
||||
@@ -260,28 +328,37 @@ void main() {
|
||||
expect(cubit.state.planData.options[0].items[0], 'Item 1');
|
||||
});
|
||||
|
||||
test('validatePriceOfAllFilledOptions returns false when some prices are invalid', () {
|
||||
cubit.setSelectedOptionIndex(0);
|
||||
cubit.setPrice(1500.0);
|
||||
cubit.setSelectedOptionIndex(1);
|
||||
cubit.setPrice(1000.0); // Invalid
|
||||
cubit.setSelectedOptionIndex(2);
|
||||
cubit.setPrice(1500.0);
|
||||
expect(cubit.validatePriceOfAllFilledOptions(), false);
|
||||
});
|
||||
test(
|
||||
'validatePriceOfAllFilledOptions returns false when some prices are invalid',
|
||||
() {
|
||||
cubit.setSelectedOptionIndex(0);
|
||||
cubit.setPrice(1500.0);
|
||||
cubit.setSelectedOptionIndex(1);
|
||||
cubit.setPrice(1000.0); // Invalid
|
||||
cubit.setSelectedOptionIndex(2);
|
||||
cubit.setPrice(1500.0);
|
||||
expect(cubit.validatePriceOfAllFilledOptions(), false);
|
||||
},
|
||||
);
|
||||
|
||||
test('validateSelectedOptionTitle returns false for short title', () {
|
||||
cubit.setSelectedOptionIndex(0);
|
||||
cubit.setOptionTitle('AB');
|
||||
expect(cubit.validateSelectedOptionTitle(), false);
|
||||
expect(cubit.state.validationState.optionsValidationState[0].titleHasError, true);
|
||||
expect(
|
||||
cubit.state.validationState.optionsValidationState[0].titleHasError,
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test('validateSelectedOptionTitle returns true for valid title', () {
|
||||
cubit.setSelectedOptionIndex(0);
|
||||
cubit.setOptionTitle('Valid Title');
|
||||
expect(cubit.validateSelectedOptionTitle(), true);
|
||||
expect(cubit.state.validationState.optionsValidationState[0].titleHasError, false);
|
||||
expect(
|
||||
cubit.state.validationState.optionsValidationState[0].titleHasError,
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('validateTitlesOfAllFilledOptions validates all options', () {
|
||||
@@ -294,37 +371,63 @@ void main() {
|
||||
expect(cubit.validateTitlesOfAllFilledOptions(), true);
|
||||
});
|
||||
|
||||
test('validateTitlesOfAllFilledOptions returns false when some titles are invalid', () {
|
||||
cubit.setSelectedOptionIndex(0);
|
||||
cubit.setOptionTitle('Valid Title');
|
||||
cubit.setSelectedOptionIndex(1);
|
||||
cubit.setOptionTitle('AB'); // Invalid
|
||||
cubit.setSelectedOptionIndex(2);
|
||||
cubit.setOptionTitle('Valid Title');
|
||||
expect(cubit.validateTitlesOfAllFilledOptions(), false);
|
||||
});
|
||||
test(
|
||||
'validateTitlesOfAllFilledOptions returns false when some titles are invalid',
|
||||
() {
|
||||
cubit.setSelectedOptionIndex(0);
|
||||
cubit.setOptionTitle('Valid Title');
|
||||
cubit.setSelectedOptionIndex(1);
|
||||
cubit.setOptionTitle('AB'); // Invalid
|
||||
cubit.setSelectedOptionIndex(2);
|
||||
cubit.setOptionTitle('Valid Title');
|
||||
expect(cubit.validateTitlesOfAllFilledOptions(), false);
|
||||
},
|
||||
);
|
||||
|
||||
test('validateTitlesOfAllFilledOptions returns early when selectedOptionIndex is null', () {
|
||||
cubit.setSelectedOptionIndex(null);
|
||||
cubit.setOptionTitle('Title 1');
|
||||
final result = cubit.validateTitlesOfAllFilledOptions();
|
||||
// Should only validate first option when selectedOptionIndex is null
|
||||
expect(result, isA<bool>());
|
||||
});
|
||||
test(
|
||||
'validateTitlesOfAllFilledOptions returns early when selectedOptionIndex is null',
|
||||
() {
|
||||
cubit.setSelectedOptionIndex(null);
|
||||
cubit.setOptionTitle('Title 1');
|
||||
final result = cubit.validateTitlesOfAllFilledOptions();
|
||||
// Should only validate first option when selectedOptionIndex is null
|
||||
expect(result, isA<bool>());
|
||||
},
|
||||
);
|
||||
|
||||
test('validateSelectedOptionDescription returns false for short description', () {
|
||||
cubit.setSelectedOptionIndex(0);
|
||||
cubit.setOptionDescription('AB');
|
||||
expect(cubit.validateSelectedOptionDescription(), false);
|
||||
expect(cubit.state.validationState.optionsValidationState[0].descriptionHasError, true);
|
||||
});
|
||||
test(
|
||||
'validateSelectedOptionDescription returns false for short description',
|
||||
() {
|
||||
cubit.setSelectedOptionIndex(0);
|
||||
cubit.setOptionDescription('AB');
|
||||
expect(cubit.validateSelectedOptionDescription(), false);
|
||||
expect(
|
||||
cubit
|
||||
.state
|
||||
.validationState
|
||||
.optionsValidationState[0]
|
||||
.descriptionHasError,
|
||||
true,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test('validateSelectedOptionDescription returns true for valid description', () {
|
||||
cubit.setSelectedOptionIndex(0);
|
||||
cubit.setOptionDescription('Valid description');
|
||||
expect(cubit.validateSelectedOptionDescription(), true);
|
||||
expect(cubit.state.validationState.optionsValidationState[0].descriptionHasError, false);
|
||||
});
|
||||
test(
|
||||
'validateSelectedOptionDescription returns true for valid description',
|
||||
() {
|
||||
cubit.setSelectedOptionIndex(0);
|
||||
cubit.setOptionDescription('Valid description');
|
||||
expect(cubit.validateSelectedOptionDescription(), true);
|
||||
expect(
|
||||
cubit
|
||||
.state
|
||||
.validationState
|
||||
.optionsValidationState[0]
|
||||
.descriptionHasError,
|
||||
false,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test('validateDescriptionsOfAllOptions validates all options', () {
|
||||
cubit.setSelectedOptionIndex(0);
|
||||
@@ -336,23 +439,29 @@ void main() {
|
||||
expect(cubit.validateDescriptionsOfAllOptions(), true);
|
||||
});
|
||||
|
||||
test('validateDescriptionsOfAllOptions returns false when some descriptions are invalid', () {
|
||||
cubit.setSelectedOptionIndex(0);
|
||||
cubit.setOptionDescription('Valid description');
|
||||
cubit.setSelectedOptionIndex(1);
|
||||
cubit.setOptionDescription('AB'); // Invalid
|
||||
cubit.setSelectedOptionIndex(2);
|
||||
cubit.setOptionDescription('Valid description');
|
||||
expect(cubit.validateDescriptionsOfAllOptions(), false);
|
||||
});
|
||||
test(
|
||||
'validateDescriptionsOfAllOptions returns false when some descriptions are invalid',
|
||||
() {
|
||||
cubit.setSelectedOptionIndex(0);
|
||||
cubit.setOptionDescription('Valid description');
|
||||
cubit.setSelectedOptionIndex(1);
|
||||
cubit.setOptionDescription('AB'); // Invalid
|
||||
cubit.setSelectedOptionIndex(2);
|
||||
cubit.setOptionDescription('Valid description');
|
||||
expect(cubit.validateDescriptionsOfAllOptions(), false);
|
||||
},
|
||||
);
|
||||
|
||||
test('validateDescriptionsOfAllOptions returns early when selectedOptionIndex is null', () {
|
||||
cubit.setSelectedOptionIndex(null);
|
||||
cubit.setOptionDescription('Description 1');
|
||||
final result = cubit.validateDescriptionsOfAllOptions();
|
||||
// Should only validate first option when selectedOptionIndex is null
|
||||
expect(result, isA<bool>());
|
||||
});
|
||||
test(
|
||||
'validateDescriptionsOfAllOptions returns early when selectedOptionIndex is null',
|
||||
() {
|
||||
cubit.setSelectedOptionIndex(null);
|
||||
cubit.setOptionDescription('Description 1');
|
||||
final result = cubit.validateDescriptionsOfAllOptions();
|
||||
// Should only validate first option when selectedOptionIndex is null
|
||||
expect(result, isA<bool>());
|
||||
},
|
||||
);
|
||||
|
||||
test('validateItemsOfAllFilledOptions validates all options', () {
|
||||
cubit.setSelectedOptionIndex(0);
|
||||
@@ -364,29 +473,41 @@ void main() {
|
||||
expect(cubit.validateItemsOfAllFilledOptions(), true);
|
||||
});
|
||||
|
||||
test('validateItemsOfAllFilledOptions returns false when some items are invalid', () {
|
||||
cubit.setSelectedOptionIndex(0);
|
||||
cubit.setOptionContent('Valid item', 0);
|
||||
cubit.setSelectedOptionIndex(1);
|
||||
cubit.setOptionContent('AB', 0); // Invalid
|
||||
cubit.setSelectedOptionIndex(2);
|
||||
cubit.setOptionContent('Valid item', 0);
|
||||
expect(cubit.validateItemsOfAllFilledOptions(), false);
|
||||
});
|
||||
test(
|
||||
'validateItemsOfAllFilledOptions returns false when some items are invalid',
|
||||
() {
|
||||
cubit.setSelectedOptionIndex(0);
|
||||
cubit.setOptionContent('Valid item', 0);
|
||||
cubit.setSelectedOptionIndex(1);
|
||||
cubit.setOptionContent('AB', 0); // Invalid
|
||||
cubit.setSelectedOptionIndex(2);
|
||||
cubit.setOptionContent('Valid item', 0);
|
||||
expect(cubit.validateItemsOfAllFilledOptions(), false);
|
||||
},
|
||||
);
|
||||
|
||||
test('validateItemsOfAllFilledOptions returns false when items are empty', () {
|
||||
cubit.setSelectedOptionIndex(0);
|
||||
expect(cubit.validateItemsOfAllFilledOptions(), false);
|
||||
expect(cubit.state.validationState.optionsValidationState[0].isItemsEmpty, true);
|
||||
});
|
||||
test(
|
||||
'validateItemsOfAllFilledOptions returns false when items are empty',
|
||||
() {
|
||||
cubit.setSelectedOptionIndex(0);
|
||||
expect(cubit.validateItemsOfAllFilledOptions(), false);
|
||||
expect(
|
||||
cubit.state.validationState.optionsValidationState[0].isItemsEmpty,
|
||||
true,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test('validateItemsOfAllFilledOptions returns early when selectedOptionIndex is null', () {
|
||||
cubit.setSelectedOptionIndex(null);
|
||||
cubit.setOptionContent('Item 1', 0);
|
||||
final result = cubit.validateItemsOfAllFilledOptions();
|
||||
// Should only validate first option when selectedOptionIndex is null
|
||||
expect(result, isA<bool>());
|
||||
});
|
||||
test(
|
||||
'validateItemsOfAllFilledOptions returns early when selectedOptionIndex is null',
|
||||
() {
|
||||
cubit.setSelectedOptionIndex(null);
|
||||
cubit.setOptionContent('Item 1', 0);
|
||||
final result = cubit.validateItemsOfAllFilledOptions();
|
||||
// Should only validate first option when selectedOptionIndex is null
|
||||
expect(result, isA<bool>());
|
||||
},
|
||||
);
|
||||
|
||||
test('validateAllFields returns false when name is invalid', () {
|
||||
cubit.setName('AB');
|
||||
@@ -456,20 +577,20 @@ void main() {
|
||||
cubit.setPlanType([true, false, false, false]);
|
||||
final image = XFile('test_image.jpg');
|
||||
cubit.setImage(image);
|
||||
|
||||
|
||||
// Заполняем все три опции, так как validateItemsOfAllFilledOptions проверяет все
|
||||
cubit.setSelectedOptionIndex(0);
|
||||
cubit.setOptionTitle('Valid Title 1');
|
||||
cubit.setPrice(1500.0);
|
||||
cubit.setOptionDescription('Valid description 1');
|
||||
cubit.setOptionContent('Valid item 1', 0);
|
||||
|
||||
|
||||
cubit.setSelectedOptionIndex(1);
|
||||
cubit.setOptionTitle('Valid Title 2');
|
||||
cubit.setPrice(2000.0);
|
||||
cubit.setOptionDescription('Valid description 2');
|
||||
cubit.setOptionContent('Valid item 2', 0);
|
||||
|
||||
|
||||
cubit.setSelectedOptionIndex(2);
|
||||
cubit.setOptionTitle('Valid Title 3');
|
||||
cubit.setPrice(2500.0);
|
||||
@@ -527,21 +648,24 @@ void main() {
|
||||
expect(cubit.validateAllFields(), false);
|
||||
});
|
||||
|
||||
test('validateAllFields returns false when option description is invalid', () {
|
||||
cubit.setName('Valid Name');
|
||||
cubit.setDetails('Valid description');
|
||||
cubit.setDuration(0);
|
||||
cubit.setPlanType([true, false, false, false]);
|
||||
final image = XFile('test_image.jpg');
|
||||
cubit.setImage(image);
|
||||
cubit.setSelectedOptionIndex(0);
|
||||
cubit.setOptionTitle('Valid Title');
|
||||
cubit.setPrice(1500.0);
|
||||
cubit.setOptionDescription('AB'); // Invalid
|
||||
cubit.setOptionContent('Valid item', 0);
|
||||
test(
|
||||
'validateAllFields returns false when option description is invalid',
|
||||
() {
|
||||
cubit.setName('Valid Name');
|
||||
cubit.setDetails('Valid description');
|
||||
cubit.setDuration(0);
|
||||
cubit.setPlanType([true, false, false, false]);
|
||||
final image = XFile('test_image.jpg');
|
||||
cubit.setImage(image);
|
||||
cubit.setSelectedOptionIndex(0);
|
||||
cubit.setOptionTitle('Valid Title');
|
||||
cubit.setPrice(1500.0);
|
||||
cubit.setOptionDescription('AB'); // Invalid
|
||||
cubit.setOptionContent('Valid item', 0);
|
||||
|
||||
expect(cubit.validateAllFields(), false);
|
||||
});
|
||||
expect(cubit.validateAllFields(), false);
|
||||
},
|
||||
);
|
||||
|
||||
test('validateAllFields returns false when option items are invalid', () {
|
||||
cubit.setName('Valid Name');
|
||||
@@ -611,115 +735,143 @@ void main() {
|
||||
expect(cubit.state.planData.options[0].items.length, 1);
|
||||
});
|
||||
|
||||
test('setOptionContent - добавляет новый элемент когда индекс больше длины', () {
|
||||
cubit.setSelectedOptionIndex(0);
|
||||
expect(cubit.state.planData.options[0].items.length, 0);
|
||||
test(
|
||||
'setOptionContent - добавляет новый элемент когда индекс больше длины',
|
||||
() {
|
||||
cubit.setSelectedOptionIndex(0);
|
||||
expect(cubit.state.planData.options[0].items.length, 0);
|
||||
|
||||
cubit.setOptionContent('Item 1', 0);
|
||||
expect(cubit.state.planData.options[0].items.length, 1);
|
||||
cubit.setOptionContent('Item 1', 0);
|
||||
expect(cubit.state.planData.options[0].items.length, 1);
|
||||
|
||||
cubit.setOptionContent('Item 2', 1);
|
||||
expect(cubit.state.planData.options[0].items.length, 2);
|
||||
});
|
||||
cubit.setOptionContent('Item 2', 1);
|
||||
expect(cubit.state.planData.options[0].items.length, 2);
|
||||
},
|
||||
);
|
||||
|
||||
test('validateTitlesOfAllFilledOptions - возвращает true когда selectedOptionIndex == null и все опции валидны', () {
|
||||
cubit.setSelectedOptionIndex(null);
|
||||
cubit.setOptionTitle('Valid Title 1');
|
||||
cubit.setSelectedOptionIndex(0);
|
||||
cubit.setOptionTitle('Valid Title 1');
|
||||
cubit.setSelectedOptionIndex(1);
|
||||
cubit.setOptionTitle('Valid Title 2');
|
||||
cubit.setSelectedOptionIndex(2);
|
||||
cubit.setOptionTitle('Valid Title 3');
|
||||
test(
|
||||
'validateTitlesOfAllFilledOptions - возвращает true когда selectedOptionIndex == null и все опции валидны',
|
||||
() {
|
||||
cubit.setSelectedOptionIndex(null);
|
||||
cubit.setOptionTitle('Valid Title 1');
|
||||
cubit.setSelectedOptionIndex(0);
|
||||
cubit.setOptionTitle('Valid Title 1');
|
||||
cubit.setSelectedOptionIndex(1);
|
||||
cubit.setOptionTitle('Valid Title 2');
|
||||
cubit.setSelectedOptionIndex(2);
|
||||
cubit.setOptionTitle('Valid Title 3');
|
||||
|
||||
cubit.setSelectedOptionIndex(null);
|
||||
final result = cubit.validateTitlesOfAllFilledOptions();
|
||||
expect(result, true);
|
||||
});
|
||||
cubit.setSelectedOptionIndex(null);
|
||||
final result = cubit.validateTitlesOfAllFilledOptions();
|
||||
expect(result, true);
|
||||
},
|
||||
);
|
||||
|
||||
test('validateTitlesOfAllFilledOptions - возвращает false когда selectedOptionIndex == null и опция 0 невалидна', () {
|
||||
cubit.setSelectedOptionIndex(0);
|
||||
cubit.setOptionTitle('AB'); // невалидное название
|
||||
cubit.setSelectedOptionIndex(null);
|
||||
test(
|
||||
'validateTitlesOfAllFilledOptions - возвращает false когда selectedOptionIndex == null и опция 0 невалидна',
|
||||
() {
|
||||
cubit.setSelectedOptionIndex(0);
|
||||
cubit.setOptionTitle('AB'); // невалидное название
|
||||
cubit.setSelectedOptionIndex(null);
|
||||
|
||||
final result = cubit.validateTitlesOfAllFilledOptions();
|
||||
expect(result, false);
|
||||
});
|
||||
final result = cubit.validateTitlesOfAllFilledOptions();
|
||||
expect(result, false);
|
||||
},
|
||||
);
|
||||
|
||||
test('validateDescriptionsOfAllOptions - возвращает true когда selectedOptionIndex == null и все описания валидны', () {
|
||||
cubit.setSelectedOptionIndex(0);
|
||||
cubit.setOptionDescription('Valid Description 1');
|
||||
cubit.setSelectedOptionIndex(1);
|
||||
cubit.setOptionDescription('Valid Description 2');
|
||||
cubit.setSelectedOptionIndex(2);
|
||||
cubit.setOptionDescription('Valid Description 3');
|
||||
test(
|
||||
'validateDescriptionsOfAllOptions - возвращает true когда selectedOptionIndex == null и все описания валидны',
|
||||
() {
|
||||
cubit.setSelectedOptionIndex(0);
|
||||
cubit.setOptionDescription('Valid Description 1');
|
||||
cubit.setSelectedOptionIndex(1);
|
||||
cubit.setOptionDescription('Valid Description 2');
|
||||
cubit.setSelectedOptionIndex(2);
|
||||
cubit.setOptionDescription('Valid Description 3');
|
||||
|
||||
cubit.setSelectedOptionIndex(null);
|
||||
final result = cubit.validateDescriptionsOfAllOptions();
|
||||
expect(result, true);
|
||||
});
|
||||
cubit.setSelectedOptionIndex(null);
|
||||
final result = cubit.validateDescriptionsOfAllOptions();
|
||||
expect(result, true);
|
||||
},
|
||||
);
|
||||
|
||||
test('validateDescriptionsOfAllOptions - возвращает false когда selectedOptionIndex == null и опция 0 невалидна', () {
|
||||
cubit.setSelectedOptionIndex(0);
|
||||
cubit.setOptionDescription('AB'); // невалидное описание
|
||||
cubit.setSelectedOptionIndex(null);
|
||||
test(
|
||||
'validateDescriptionsOfAllOptions - возвращает false когда selectedOptionIndex == null и опция 0 невалидна',
|
||||
() {
|
||||
cubit.setSelectedOptionIndex(0);
|
||||
cubit.setOptionDescription('AB'); // невалидное описание
|
||||
cubit.setSelectedOptionIndex(null);
|
||||
|
||||
final result = cubit.validateDescriptionsOfAllOptions();
|
||||
expect(result, false);
|
||||
});
|
||||
final result = cubit.validateDescriptionsOfAllOptions();
|
||||
expect(result, false);
|
||||
},
|
||||
);
|
||||
|
||||
test('validateItemsOfAllFilledOptions - возвращает true когда selectedOptionIndex == null и все items валидны', () {
|
||||
cubit.setSelectedOptionIndex(0);
|
||||
cubit.setOptionContent('Valid Item 1', 0);
|
||||
cubit.setSelectedOptionIndex(1);
|
||||
cubit.setOptionContent('Valid Item 2', 0);
|
||||
cubit.setSelectedOptionIndex(2);
|
||||
cubit.setOptionContent('Valid Item 3', 0);
|
||||
test(
|
||||
'validateItemsOfAllFilledOptions - возвращает true когда selectedOptionIndex == null и все items валидны',
|
||||
() {
|
||||
cubit.setSelectedOptionIndex(0);
|
||||
cubit.setOptionContent('Valid Item 1', 0);
|
||||
cubit.setSelectedOptionIndex(1);
|
||||
cubit.setOptionContent('Valid Item 2', 0);
|
||||
cubit.setSelectedOptionIndex(2);
|
||||
cubit.setOptionContent('Valid Item 3', 0);
|
||||
|
||||
cubit.setSelectedOptionIndex(null);
|
||||
final result = cubit.validateItemsOfAllFilledOptions();
|
||||
expect(result, true);
|
||||
});
|
||||
cubit.setSelectedOptionIndex(null);
|
||||
final result = cubit.validateItemsOfAllFilledOptions();
|
||||
expect(result, true);
|
||||
},
|
||||
);
|
||||
|
||||
test('validateItemsOfAllFilledOptions - возвращает false когда selectedOptionIndex == null и опция 0 имеет пустые items', () {
|
||||
cubit.setSelectedOptionIndex(0);
|
||||
// items пустые
|
||||
cubit.setSelectedOptionIndex(null);
|
||||
test(
|
||||
'validateItemsOfAllFilledOptions - возвращает false когда selectedOptionIndex == null и опция 0 имеет пустые items',
|
||||
() {
|
||||
cubit.setSelectedOptionIndex(0);
|
||||
// items пустые
|
||||
cubit.setSelectedOptionIndex(null);
|
||||
|
||||
final result = cubit.validateItemsOfAllFilledOptions();
|
||||
expect(result, false);
|
||||
});
|
||||
final result = cubit.validateItemsOfAllFilledOptions();
|
||||
expect(result, false);
|
||||
},
|
||||
);
|
||||
|
||||
test('validatePriceOfAllFilledOptions - возвращает true когда все цены валидны', () {
|
||||
cubit.setSelectedOptionIndex(0);
|
||||
cubit.setPrice(1500.0);
|
||||
cubit.setSelectedOptionIndex(1);
|
||||
cubit.setPrice(2000.0);
|
||||
cubit.setSelectedOptionIndex(2);
|
||||
cubit.setPrice(2500.0);
|
||||
test(
|
||||
'validatePriceOfAllFilledOptions - возвращает true когда все цены валидны',
|
||||
() {
|
||||
cubit.setSelectedOptionIndex(0);
|
||||
cubit.setPrice(1500.0);
|
||||
cubit.setSelectedOptionIndex(1);
|
||||
cubit.setPrice(2000.0);
|
||||
cubit.setSelectedOptionIndex(2);
|
||||
cubit.setPrice(2500.0);
|
||||
|
||||
final result = cubit.validatePriceOfAllFilledOptions();
|
||||
expect(result, true);
|
||||
});
|
||||
final result = cubit.validatePriceOfAllFilledOptions();
|
||||
expect(result, true);
|
||||
},
|
||||
);
|
||||
|
||||
test('validatePriceOfAllFilledOptions - возвращает false когда опция 1 имеет невалидную цену', () {
|
||||
cubit.setSelectedOptionIndex(0);
|
||||
cubit.setPrice(1500.0);
|
||||
cubit.setSelectedOptionIndex(1);
|
||||
cubit.setPrice(1000.0); // невалидная цена
|
||||
cubit.setSelectedOptionIndex(2);
|
||||
cubit.setPrice(2500.0);
|
||||
|
||||
final result = cubit.validatePriceOfAllFilledOptions();
|
||||
expect(result, false);
|
||||
});
|
||||
|
||||
test('setPlanType - не валидирует когда type.any((elem) => elem) == false', () {
|
||||
cubit.setPlanType([false, false, false, false]);
|
||||
expect(cubit.state.planData.type, [false, false, false, false]);
|
||||
// Валидация не должна вызываться, так как все элементы false
|
||||
});
|
||||
test(
|
||||
'validatePriceOfAllFilledOptions - возвращает false когда опция 1 имеет невалидную цену',
|
||||
() {
|
||||
cubit.setSelectedOptionIndex(0);
|
||||
cubit.setPrice(1500.0);
|
||||
cubit.setSelectedOptionIndex(1);
|
||||
cubit.setPrice(1000.0); // невалидная цена
|
||||
cubit.setSelectedOptionIndex(2);
|
||||
cubit.setPrice(2500.0);
|
||||
|
||||
final result = cubit.validatePriceOfAllFilledOptions();
|
||||
expect(result, false);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'setPlanType - не валидирует когда type.any((elem) => elem) == false',
|
||||
() {
|
||||
cubit.setPlanType([false, false, false, false]);
|
||||
expect(cubit.state.planData.type, [false, false, false, false]);
|
||||
// Валидация не должна вызываться, так как все элементы false
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -3,8 +3,9 @@ import 'package:eatvio_app/core/app_initializer.dart';
|
||||
import 'package:eatvio_app/core/locator_service.dart';
|
||||
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
||||
import 'package:eatvio_app/features/create_plan/presentation/cubit/create_plan_cubit.dart';
|
||||
import 'package:eatvio_app/features/create_plan/presentation/cubit/create_plan_state.dart';
|
||||
import 'package:eatvio_app/features/create_plan/presentation/widgets/option_widget.dart';
|
||||
import 'package:eatvio_app/features/create_plan/presentation/widgets/select_exact_option_widget.dart';
|
||||
import 'package:eatvio_app/features/create_plan/repositories/create_plan_repository.dart';
|
||||
import 'package:eatvio_app/generated/l10n.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
@@ -12,12 +13,7 @@ import 'package:flutter_dotenv/flutter_dotenv.dart';
|
||||
import 'package:flutter_localizations/flutter_localizations.dart';
|
||||
import 'package:flutter_portal/flutter_portal.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:mockito/annotations.dart';
|
||||
import 'package:mockito/mockito.dart';
|
||||
|
||||
import 'option_widget_test.mocks.dart';
|
||||
|
||||
@GenerateNiceMocks([MockSpec<CreatePlanCubit>()])
|
||||
void main() {
|
||||
group('OptionWidget smoke', () {
|
||||
setUpAll(() async {
|
||||
@@ -47,18 +43,8 @@ void main() {
|
||||
tester.view.physicalSize = const Size(800, 1200);
|
||||
tester.view.devicePixelRatio = 1.0;
|
||||
|
||||
final cubit = MockCreatePlanCubit();
|
||||
final state = CreatePlanState.initial();
|
||||
|
||||
when(cubit.state).thenReturn(state);
|
||||
when(cubit.stream).thenAnswer((_) => Stream.value(state));
|
||||
when(cubit.close()).thenAnswer((_) async => Future<void>.value());
|
||||
when(cubit.setOptionTitle(any)).thenReturn(null);
|
||||
when(cubit.setOptionDescription(any)).thenReturn(null);
|
||||
when(cubit.setPrice(any)).thenReturn(null);
|
||||
when(cubit.setOptionContent(any, any)).thenReturn(null);
|
||||
when(cubit.removeOptionContentItem(any)).thenReturn(null);
|
||||
when(cubit.togglePersonalizedDietOptionState()).thenReturn(null);
|
||||
final cubit = CreatePlanCubit(createPlanRepository: _FakeCreatePlanRepository());
|
||||
addTearDown(cubit.close);
|
||||
|
||||
await tester.pumpWidget(BlocProvider<CreatePlanCubit>.value(value: cubit, child: wrap(const OptionWidget())));
|
||||
await tester.pump();
|
||||
@@ -66,5 +52,61 @@ void main() {
|
||||
|
||||
expect(find.byType(OptionWidget), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('keeps title and price independent when switching options', (tester) async {
|
||||
tester.view.physicalSize = const Size(800, 1600);
|
||||
tester.view.devicePixelRatio = 1.0;
|
||||
|
||||
final cubit = CreatePlanCubit(createPlanRepository: _FakeCreatePlanRepository());
|
||||
cubit.setSelectedOptionIndex(0);
|
||||
addTearDown(cubit.close);
|
||||
|
||||
await tester.pumpWidget(
|
||||
BlocProvider<CreatePlanCubit>.value(
|
||||
value: cubit,
|
||||
child: wrap(
|
||||
const Column(
|
||||
children: [
|
||||
SelectExactOptionWidget(),
|
||||
Expanded(child: SingleChildScrollView(child: OptionWidget())),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 50));
|
||||
|
||||
final firstFields = find.byType(TextFormField);
|
||||
await tester.enterText(firstFields.at(0), 'Economy title');
|
||||
await tester.enterText(firstFields.at(3), '1500');
|
||||
await tester.pump();
|
||||
|
||||
await tester.tap(find.text('2'));
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
|
||||
expect(cubit.state.selectedOptionIndex, 1);
|
||||
|
||||
final secondFields = find.byType(TextFormField);
|
||||
await tester.enterText(secondFields.at(0), 'Standard title');
|
||||
await tester.enterText(secondFields.at(3), '2500');
|
||||
await tester.pump();
|
||||
|
||||
expect(cubit.state.planData.options[0].short, 'Economy title');
|
||||
expect(cubit.state.planData.options[1].short, 'Standard title');
|
||||
expect(cubit.state.planData.options[0].price, 1500.0);
|
||||
expect(cubit.state.planData.options[1].price, 2500.0);
|
||||
|
||||
await tester.tap(find.text('1'));
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text('Economy title'), findsOneWidget);
|
||||
expect(cubit.state.planData.options[1].short, 'Standard title');
|
||||
expect(cubit.state.planData.options[1].price, 2500.0);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
class _FakeCreatePlanRepository extends Fake implements CreatePlanRepository {}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ void main() {
|
||||
String? slug,
|
||||
int? id,
|
||||
bool? disableActions,
|
||||
bool isAuthorAdmin = false,
|
||||
}) {
|
||||
return MaterialApp(
|
||||
theme: ThemeHelper().themeData(),
|
||||
@@ -42,6 +43,7 @@ void main() {
|
||||
slug: slug,
|
||||
id: id,
|
||||
disableActions: disableActions,
|
||||
isAuthorAdmin: isAuthorAdmin,
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -71,7 +73,23 @@ void main() {
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(MoreOptions), findsOneWidget);
|
||||
expect(find.text('Пожаловаться'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('hides complain option when author is admin', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
createTestWidget(
|
||||
complainedContentType: ComplainedContent.diet,
|
||||
id: 1,
|
||||
slug: 'test-slug',
|
||||
isAuthorAdmin: true,
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Пожаловаться'), findsNothing);
|
||||
expect(find.text('Скопировать ссылку'), findsOneWidget);
|
||||
expect(find.text('Поделиться'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('disables actions when disableActions is true', (tester) async {
|
||||
|
||||
@@ -56,6 +56,33 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
group('Author', () {
|
||||
test('fromJson читает author_roles и определяет админа', () {
|
||||
final author = Author.fromJson({
|
||||
'author_slug': 'admin-user',
|
||||
'author_name': 'Admin',
|
||||
'author_profile_icon': 'icon.jpg',
|
||||
'author_is_me': false,
|
||||
'author_roles': ['admin', 'consultant'],
|
||||
});
|
||||
|
||||
expect(author.authorRoles, ['admin', 'consultant']);
|
||||
expect(author.isAdmin, isTrue);
|
||||
});
|
||||
|
||||
test('fromJson без author_roles не считает автора админом', () {
|
||||
final author = Author.fromJson({
|
||||
'author_slug': 'regular-user',
|
||||
'author_name': 'User',
|
||||
'author_profile_icon': 'icon.jpg',
|
||||
'author_is_me': false,
|
||||
});
|
||||
|
||||
expect(author.authorRoles, isEmpty);
|
||||
expect(author.isAdmin, isFalse);
|
||||
});
|
||||
});
|
||||
|
||||
group('DietState', () {
|
||||
test('toString возвращает правильные значения', () {
|
||||
expect(DietState.active.toString(), 'active');
|
||||
|
||||
@@ -52,5 +52,14 @@ void main() {
|
||||
expect(posts[2].videoEmbedUrl, contains('rutube.ru/play/embed'));
|
||||
expect(posts[2].author.isEntitySubscribedByMe, isNull);
|
||||
});
|
||||
|
||||
test('treats API placeholder cover as missing image', () {
|
||||
expect(PostFeedMapper.resolveMediaUrl('images/element-image-placeholder.svg'), isEmpty);
|
||||
expect(PostFeedMapper.hasRenderableImage('images/element-image-placeholder.svg'), isFalse);
|
||||
expect(
|
||||
PostFeedMapper.hasVisibleMedia(image: '', images: const [], videoEmbedUrl: null),
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:eatvio_app/features/feed/models/post_feed_pagination_model.dart';
|
||||
import 'package:eatvio_app/features/feed/presentation/widgets/blog_post_media_content.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:network_image_mock/network_image_mock.dart';
|
||||
|
||||
void main() {
|
||||
const gallery = [
|
||||
FeedGalleryImage(
|
||||
url: 'https://example.com/a.jpg',
|
||||
orientation: 'horizontal',
|
||||
),
|
||||
FeedGalleryImage(url: 'https://example.com/b.jpg', orientation: 'vertical'),
|
||||
];
|
||||
|
||||
Widget wrap(Widget child) {
|
||||
return MaterialApp(
|
||||
home: MediaQuery(
|
||||
data: const MediaQueryData(size: Size(390, 844)),
|
||||
child: Scaffold(body: child),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
BlogPostMediaContent media({
|
||||
List<FeedGalleryImage> images = gallery,
|
||||
String imageUrl = '',
|
||||
}) {
|
||||
return BlogPostMediaContent(
|
||||
galleryImages: images,
|
||||
imageUrl: imageUrl,
|
||||
videoEmbedUrl: null,
|
||||
videoUrlThumb: null,
|
||||
currentGalleryIndex: 0,
|
||||
onOpenGallery: (_, _) {},
|
||||
onGalleryChanged: (_) {},
|
||||
);
|
||||
}
|
||||
|
||||
testWidgets('gallery slides use fitWidth so photos fill the card width', (
|
||||
tester,
|
||||
) async {
|
||||
await mockNetworkImagesFor(() async {
|
||||
await tester.pumpWidget(wrap(media()));
|
||||
await tester.pump();
|
||||
|
||||
final images = tester.widgetList<CachedNetworkImage>(
|
||||
find.byType(CachedNetworkImage),
|
||||
);
|
||||
expect(images, isNotEmpty);
|
||||
for (final image in images) {
|
||||
expect(image.fit, BoxFit.fitWidth);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
testWidgets('single gallery image uses fitWidth without a forced height', (
|
||||
tester,
|
||||
) async {
|
||||
await mockNetworkImagesFor(() async {
|
||||
await tester.pumpWidget(wrap(media(images: [gallery.first])));
|
||||
await tester.pump();
|
||||
|
||||
final image = tester.widget<CachedNetworkImage>(
|
||||
find.byType(CachedNetworkImage),
|
||||
);
|
||||
expect(image.fit, BoxFit.fitWidth);
|
||||
expect(image.height, isNull);
|
||||
});
|
||||
});
|
||||
|
||||
testWidgets('cover-only image keeps fitWidth', (tester) async {
|
||||
await mockNetworkImagesFor(() async {
|
||||
await tester.pumpWidget(
|
||||
wrap(
|
||||
media(images: const [], imageUrl: 'https://example.com/cover.jpg'),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
final image = tester.widget<CachedNetworkImage>(
|
||||
find.byType(CachedNetworkImage),
|
||||
);
|
||||
expect(image.fit, BoxFit.fitWidth);
|
||||
});
|
||||
});
|
||||
|
||||
testWidgets('empty cover does not reserve media height', (tester) async {
|
||||
await tester.pumpWidget(wrap(media(images: const [], imageUrl: '')));
|
||||
await tester.pump();
|
||||
|
||||
expect(find.byType(CachedNetworkImage), findsNothing);
|
||||
expect(tester.getSize(find.byType(BlogPostMediaContent)).height, 0);
|
||||
});
|
||||
|
||||
testWidgets('placeholder cover does not reserve media height', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
wrap(
|
||||
media(
|
||||
images: const [],
|
||||
imageUrl: 'images/element-image-placeholder.svg',
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
expect(find.byType(CachedNetworkImage), findsNothing);
|
||||
expect(tester.getSize(find.byType(BlogPostMediaContent)).height, 0);
|
||||
});
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
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/widgets/loader.dart';
|
||||
import 'package:eatvio_app/features/glossary/presentation/pages/glossary_screen.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
@@ -46,6 +47,7 @@ void main() {
|
||||
expect(find.byType(GlossaryScreen), findsOneWidget);
|
||||
expect(find.byType(Scaffold), findsOneWidget);
|
||||
expect(find.text('Глоссарий'), findsOneWidget);
|
||||
expect(find.byType(Loader), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('GlossaryScreen создается с правильным passKey', (WidgetTester tester) async {
|
||||
@@ -71,6 +73,66 @@ void main() {
|
||||
|
||||
expect(find.byType(GlossaryScreen), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('показывает стандартный лоадер при загрузке WebView', (WidgetTester tester) async {
|
||||
const passKey = 'test-pass-key';
|
||||
|
||||
final router = GoRouter(
|
||||
initialLocation: '/',
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: '/',
|
||||
builder: (context, state) => const GlossaryScreen(passKey: passKey),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp.router(
|
||||
routerConfig: router,
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pump();
|
||||
|
||||
expect(find.byType(Loader), findsOneWidget);
|
||||
});
|
||||
});
|
||||
|
||||
group('shouldHideGlossaryLoader', () {
|
||||
test('не скрывает лоадер на странице login_with_pass_key', () {
|
||||
expect(
|
||||
shouldHideGlossaryLoader(
|
||||
url: 'https://eatvio.ru/login_with_pass_key?id=1',
|
||||
progress: 100,
|
||||
),
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
|
||||
test('не скрывает лоадер пока URL ещё не известен', () {
|
||||
expect(shouldHideGlossaryLoader(url: '', progress: 80), isFalse);
|
||||
});
|
||||
|
||||
test('не скрывает лоадер пока прогресс глоссария ниже порога', () {
|
||||
expect(
|
||||
shouldHideGlossaryLoader(
|
||||
url: 'https://eatvio.ru/product_nutritional_value',
|
||||
progress: 39,
|
||||
),
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
|
||||
test('скрывает лоадер когда страница глоссария уже начала отрисовываться', () {
|
||||
expect(
|
||||
shouldHideGlossaryLoader(
|
||||
url: 'https://eatvio.ru/product_nutritional_value',
|
||||
progress: 40,
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
||||
import 'package:eatvio_app/features/my_finance/presentation/pages/card_settings.dart';
|
||||
import 'package:eatvio_app/features/notifications/models/notification_model.dart';
|
||||
import 'package:eatvio_app/features/notifications/presentation/widgets/notification_widget.dart';
|
||||
import 'package:eatvio_app/generated/l10n.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:flutter_localizations/flutter_localizations.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
void main() {
|
||||
group('NotificationWidget', () {
|
||||
@@ -21,13 +23,13 @@ void main() {
|
||||
supportedLocales: const [Locale('ru'), Locale('en')],
|
||||
locale: const Locale('ru'),
|
||||
theme: ThemeHelper().themeData(),
|
||||
home: Scaffold(
|
||||
body: NotificationWidget(notification: notification),
|
||||
),
|
||||
home: Scaffold(body: NotificationWidget(notification: notification)),
|
||||
);
|
||||
}
|
||||
|
||||
testWidgets('displays PlanSubscriptionStartedToAuthorNotification', (tester) async {
|
||||
testWidgets('displays PlanSubscriptionStartedToAuthorNotification', (
|
||||
tester,
|
||||
) async {
|
||||
final notification = PlanSubscriptionStartedToAuthorNotification(
|
||||
type: NotificationType.planSubscriptionStartedToAuthor,
|
||||
planName: 'Test Plan',
|
||||
@@ -47,7 +49,9 @@ void main() {
|
||||
expect(find.byType(NotificationWidget), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('displays PlanSubscriptionCanceledToAuthorNotification', (tester) async {
|
||||
testWidgets('displays PlanSubscriptionCanceledToAuthorNotification', (
|
||||
tester,
|
||||
) async {
|
||||
final notification = PlanSubscriptionCanceledToAuthorNotification(
|
||||
type: NotificationType.planSubscriptionCanceledToAuthor,
|
||||
planName: 'Test Plan',
|
||||
@@ -66,7 +70,9 @@ void main() {
|
||||
expect(find.byType(NotificationWidget), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('displays ChosenPlanStartedToClientNotification', (tester) async {
|
||||
testWidgets('displays ChosenPlanStartedToClientNotification', (
|
||||
tester,
|
||||
) async {
|
||||
final notification = ChosenPlanStartedToClientNotification(
|
||||
type: NotificationType.chosenPlanStartedToClient,
|
||||
planName: 'Test Plan',
|
||||
@@ -83,7 +89,9 @@ void main() {
|
||||
expect(find.byType(NotificationWidget), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('displays ChosenPlanTomorrowReminderToClientNotification', (tester) async {
|
||||
testWidgets('displays ChosenPlanTomorrowReminderToClientNotification', (
|
||||
tester,
|
||||
) async {
|
||||
final notification = ChosenPlanTomorrowReminderToClientNotification(
|
||||
type: NotificationType.chosenPlanTomorrowReminderToClient,
|
||||
planName: 'Test Plan',
|
||||
@@ -131,7 +139,9 @@ void main() {
|
||||
expect(find.byType(NotificationWidget), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('displays PersonalizedDietIsDefinedNotification', (tester) async {
|
||||
testWidgets('displays PersonalizedDietIsDefinedNotification', (
|
||||
tester,
|
||||
) async {
|
||||
final notification = PersonalizedDietIsDefinedNotification(
|
||||
type: NotificationType.personalizedDietIsDefined,
|
||||
isRead: false,
|
||||
@@ -144,7 +154,9 @@ void main() {
|
||||
expect(find.byType(NotificationWidget), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('displays ChosenPlanFinishedToClientNotification', (tester) async {
|
||||
testWidgets('displays ChosenPlanFinishedToClientNotification', (
|
||||
tester,
|
||||
) async {
|
||||
final notification = ChosenPlanFinishedToClientNotification(
|
||||
type: NotificationType.chosenPlanFinishedToClient,
|
||||
planName: 'Test Plan',
|
||||
@@ -163,7 +175,9 @@ void main() {
|
||||
expect(find.byType(NotificationWidget), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('displays PlanSubscriptionCanceledToCustomerNotification', (tester) async {
|
||||
testWidgets('displays PlanSubscriptionCanceledToCustomerNotification', (
|
||||
tester,
|
||||
) async {
|
||||
final notification = PlanSubscriptionCanceledToCustomerNotification(
|
||||
type: NotificationType.planSubscriptionCanceledToCustomer,
|
||||
planName: 'Test Plan',
|
||||
@@ -182,7 +196,9 @@ void main() {
|
||||
expect(find.byType(NotificationWidget), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('displays ChosenPlanStartedToConsultantNotification', (tester) async {
|
||||
testWidgets('displays ChosenPlanStartedToConsultantNotification', (
|
||||
tester,
|
||||
) async {
|
||||
final notification = ChosenPlanStartedToConsultantNotification(
|
||||
type: NotificationType.chosenPlanStartedToConsultant,
|
||||
planName: 'Test Plan',
|
||||
@@ -201,7 +217,9 @@ void main() {
|
||||
expect(find.byType(NotificationWidget), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('displays ChosenPlanTomorrowReminderToConsultantNotification', (tester) async {
|
||||
testWidgets('displays ChosenPlanTomorrowReminderToConsultantNotification', (
|
||||
tester,
|
||||
) async {
|
||||
final notification = ChosenPlanTomorrowReminderToConsultantNotification(
|
||||
type: NotificationType.chosenPlanTomorrowReminderToConsultant,
|
||||
planName: 'Test Plan',
|
||||
@@ -217,7 +235,9 @@ void main() {
|
||||
expect(find.byType(NotificationWidget), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('displays PlanSubscriptionPaidToAuthorNotification', (tester) async {
|
||||
testWidgets('displays PlanSubscriptionPaidToAuthorNotification', (
|
||||
tester,
|
||||
) async {
|
||||
final notification = PlanSubscriptionPaidToAuthorNotification(
|
||||
type: NotificationType.planSubscriptionPaidToAuthor,
|
||||
planName: 'Test Plan',
|
||||
@@ -236,24 +256,86 @@ void main() {
|
||||
expect(find.byType(NotificationWidget), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('displays ChosenPlanFinishedToConsultantNotification with button', (tester) async {
|
||||
final notification = ChosenPlanFinishedToConsultantNotification(
|
||||
type: NotificationType.chosenPlanFinishedToConsultant,
|
||||
planName: 'Test Plan',
|
||||
customerName: 'Test Customer',
|
||||
planId: 1,
|
||||
chosenPlanId: 1,
|
||||
clientPhotoPath: '',
|
||||
clientId: 0,
|
||||
isRead: false,
|
||||
createdAt: testDate,
|
||||
);
|
||||
testWidgets(
|
||||
'displays ChosenPlanFinishedToConsultantNotification with button',
|
||||
(tester) async {
|
||||
final notification = ChosenPlanFinishedToConsultantNotification(
|
||||
type: NotificationType.chosenPlanFinishedToConsultant,
|
||||
planName: 'Test Plan',
|
||||
customerName: 'Test Customer',
|
||||
planId: 1,
|
||||
chosenPlanId: 1,
|
||||
clientPhotoPath: '',
|
||||
clientId: 0,
|
||||
isRead: false,
|
||||
createdAt: testDate,
|
||||
);
|
||||
|
||||
await tester.pumpWidget(createTestWidget(notification));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.pumpWidget(createTestWidget(notification));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(NotificationWidget), findsOneWidget);
|
||||
});
|
||||
expect(find.byType(NotificationWidget), findsOneWidget);
|
||||
expect(find.text('Получить выплату'), findsOneWidget);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'collect charge from finished notification uses chosenPlanId, not planId',
|
||||
(tester) async {
|
||||
int? capturedChosenPlanId;
|
||||
final notification = ChosenPlanFinishedToConsultantNotification(
|
||||
type: NotificationType.chosenPlanFinishedToConsultant,
|
||||
planName: 'Test Plan',
|
||||
customerName: 'Test Customer',
|
||||
planId: 99,
|
||||
chosenPlanId: 42,
|
||||
clientPhotoPath: '',
|
||||
clientId: 0,
|
||||
isRead: false,
|
||||
createdAt: testDate,
|
||||
);
|
||||
|
||||
final router = GoRouter(
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: '/',
|
||||
builder: (context, state) => Scaffold(
|
||||
body: NotificationWidget(notification: notification),
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
path: CardSettingsScreen.path,
|
||||
builder: (context, state) {
|
||||
capturedChosenPlanId = state.extra as int?;
|
||||
return const Scaffold(body: Text('card-settings'));
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp.router(
|
||||
localizationsDelegates: const [
|
||||
S.delegate,
|
||||
GlobalMaterialLocalizations.delegate,
|
||||
GlobalWidgetsLocalizations.delegate,
|
||||
GlobalCupertinoLocalizations.delegate,
|
||||
],
|
||||
supportedLocales: const [Locale('ru'), Locale('en')],
|
||||
locale: const Locale('ru'),
|
||||
theme: ThemeHelper().themeData(),
|
||||
routerConfig: router,
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.text('Получить выплату'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('card-settings'), findsOneWidget);
|
||||
expect(capturedChosenPlanId, 42);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('displays ChosenPlanLastDayFinishedForUser', (tester) async {
|
||||
final notification = ChosenPlanLastDayFinishedForUser(
|
||||
@@ -587,7 +669,9 @@ void main() {
|
||||
expect(find.byType(NotificationWidget), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('displays PersonalDietCreationNeededNotification', (tester) async {
|
||||
testWidgets('displays PersonalDietCreationNeededNotification', (
|
||||
tester,
|
||||
) async {
|
||||
final notification = PersonalDietCreationNeededNotification(
|
||||
type: NotificationType.personalDietCreationNeeded,
|
||||
userName: 'Test Client',
|
||||
@@ -603,7 +687,9 @@ void main() {
|
||||
expect(find.byType(NotificationWidget), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('displays ChosenPlanClientLastDayFinishedNotification', (tester) async {
|
||||
testWidgets('displays ChosenPlanClientLastDayFinishedNotification', (
|
||||
tester,
|
||||
) async {
|
||||
final notification = ChosenPlanClientLastDayFinishedNotification(
|
||||
type: NotificationType.chosenPlanClientLastDayFinished,
|
||||
planName: 'Test Plan',
|
||||
@@ -620,7 +706,9 @@ void main() {
|
||||
expect(find.byType(NotificationWidget), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('displays ChosenPlanClientLastDayStartedNotification', (tester) async {
|
||||
testWidgets('displays ChosenPlanClientLastDayStartedNotification', (
|
||||
tester,
|
||||
) async {
|
||||
final notification = ChosenPlanClientLastDayStartedNotification(
|
||||
type: NotificationType.chosenPlanClientLastDayStarted,
|
||||
planName: 'Test Plan',
|
||||
@@ -653,47 +741,58 @@ void main() {
|
||||
expect(find.byType(NotificationWidget), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('displays ClientStartPlanSubscriptionToSubscriberNotification', (tester) async {
|
||||
final notification = ClientStartPlanSubscriptionToSubscriberNotification(
|
||||
type: NotificationType.clientStartPlanSubscriptionToSubscriber,
|
||||
planName: 'Test Plan',
|
||||
planId: 1,
|
||||
authorName: '',
|
||||
authorId: 0,
|
||||
authorPhotoPath: '',
|
||||
chatUrl: '',
|
||||
chatId: '',
|
||||
startDate: '',
|
||||
isRead: false,
|
||||
createdAt: testDate,
|
||||
);
|
||||
testWidgets(
|
||||
'displays ClientStartPlanSubscriptionToSubscriberNotification',
|
||||
(tester) async {
|
||||
final notification =
|
||||
ClientStartPlanSubscriptionToSubscriberNotification(
|
||||
type: NotificationType.clientStartPlanSubscriptionToSubscriber,
|
||||
planName: 'Test Plan',
|
||||
planId: 1,
|
||||
authorName: '',
|
||||
authorId: 0,
|
||||
authorPhotoPath: '',
|
||||
chatUrl: '',
|
||||
chatId: '',
|
||||
startDate: '',
|
||||
isRead: false,
|
||||
createdAt: testDate,
|
||||
);
|
||||
|
||||
await tester.pumpWidget(createTestWidget(notification));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.pumpWidget(createTestWidget(notification));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(NotificationWidget), findsOneWidget);
|
||||
});
|
||||
expect(find.byType(NotificationWidget), findsOneWidget);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('displays ConsultantConfirmPlanSubscriptionToSubscriberNotification', (tester) async {
|
||||
final notification = ConsultantConfirmPlanSubscriptionToSubscriberNotification(
|
||||
type: NotificationType.consultantConfirmPlanSubscriptionToSubscriber,
|
||||
planName: 'Test Plan',
|
||||
planId: 1,
|
||||
consultantName: 'Test Consultant',
|
||||
authorId: 0,
|
||||
authorPhotoPath: '',
|
||||
chatUrl: '',
|
||||
isRead: false,
|
||||
createdAt: testDate,
|
||||
);
|
||||
testWidgets(
|
||||
'displays ConsultantConfirmPlanSubscriptionToSubscriberNotification',
|
||||
(tester) async {
|
||||
final notification =
|
||||
ConsultantConfirmPlanSubscriptionToSubscriberNotification(
|
||||
type: NotificationType
|
||||
.consultantConfirmPlanSubscriptionToSubscriber,
|
||||
planName: 'Test Plan',
|
||||
planId: 1,
|
||||
consultantName: 'Test Consultant',
|
||||
authorId: 0,
|
||||
authorPhotoPath: '',
|
||||
chatUrl: '',
|
||||
isRead: false,
|
||||
createdAt: testDate,
|
||||
);
|
||||
|
||||
await tester.pumpWidget(createTestWidget(notification));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.pumpWidget(createTestWidget(notification));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(NotificationWidget), findsOneWidget);
|
||||
});
|
||||
expect(find.byType(NotificationWidget), findsOneWidget);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('displays ClientPaidPlanSubscriptionToAuthorNotification', (tester) async {
|
||||
testWidgets('displays ClientPaidPlanSubscriptionToAuthorNotification', (
|
||||
tester,
|
||||
) async {
|
||||
final notification = ClientPaidPlanSubscriptionToAuthorNotification(
|
||||
type: NotificationType.clientPaidPlanSubscriptionToAuthor,
|
||||
planName: 'Test Plan',
|
||||
@@ -712,7 +811,9 @@ void main() {
|
||||
expect(find.byType(NotificationWidget), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('displays ConsultantLeftDiaryCommentNotification', (tester) async {
|
||||
testWidgets('displays ConsultantLeftDiaryCommentNotification', (
|
||||
tester,
|
||||
) async {
|
||||
final notification = ConsultantLeftDiaryCommentNotification(
|
||||
type: NotificationType.consultantLeftDiaryComment,
|
||||
chosenPlanId: 1,
|
||||
@@ -731,26 +832,32 @@ void main() {
|
||||
expect(find.byType(NotificationWidget), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('displays ConsultantConfirmPlanSubscriptionToAuthorNotification', (tester) async {
|
||||
final notification = ConsultantConfirmPlanSubscriptionToAuthorNotification(
|
||||
type: NotificationType.consultantConfirmPlanSubscriptionToAuthor,
|
||||
planName: 'Test Plan',
|
||||
planId: 1,
|
||||
clientName: 'Test Consultant',
|
||||
userId: 0,
|
||||
userPhotoPath: '',
|
||||
chatUrl: '',
|
||||
isRead: false,
|
||||
createdAt: testDate,
|
||||
);
|
||||
testWidgets(
|
||||
'displays ConsultantConfirmPlanSubscriptionToAuthorNotification',
|
||||
(tester) async {
|
||||
final notification =
|
||||
ConsultantConfirmPlanSubscriptionToAuthorNotification(
|
||||
type: NotificationType.consultantConfirmPlanSubscriptionToAuthor,
|
||||
planName: 'Test Plan',
|
||||
planId: 1,
|
||||
clientName: 'Test Consultant',
|
||||
userId: 0,
|
||||
userPhotoPath: '',
|
||||
chatUrl: '',
|
||||
isRead: false,
|
||||
createdAt: testDate,
|
||||
);
|
||||
|
||||
await tester.pumpWidget(createTestWidget(notification));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.pumpWidget(createTestWidget(notification));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(NotificationWidget), findsOneWidget);
|
||||
});
|
||||
expect(find.byType(NotificationWidget), findsOneWidget);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('displays ClientPaidPlanSubscriptionToSubscriberNotification', (tester) async {
|
||||
testWidgets('displays ClientPaidPlanSubscriptionToSubscriberNotification', (
|
||||
tester,
|
||||
) async {
|
||||
final notification = ClientPaidPlanSubscriptionToSubscriberNotification(
|
||||
type: NotificationType.clientPaidPlanSubscriptionToSubscriber,
|
||||
planName: 'Test Plan',
|
||||
@@ -769,7 +876,9 @@ void main() {
|
||||
expect(find.byType(NotificationWidget), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('displays ClientStartPlanSubscriptionToAuthorNotification', (tester) async {
|
||||
testWidgets('displays ClientStartPlanSubscriptionToAuthorNotification', (
|
||||
tester,
|
||||
) async {
|
||||
final notification = ClientStartPlanSubscriptionToAuthorNotification(
|
||||
type: NotificationType.clientStartPlanSubscriptionToAuthor,
|
||||
planName: 'Test Plan',
|
||||
@@ -789,7 +898,9 @@ void main() {
|
||||
expect(find.byType(NotificationWidget), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('displays ClientCancelPlanSubscriptionToAuthorNotification', (tester) async {
|
||||
testWidgets('displays ClientCancelPlanSubscriptionToAuthorNotification', (
|
||||
tester,
|
||||
) async {
|
||||
final notification = ClientCancelPlanSubscriptionToAuthorNotification(
|
||||
type: NotificationType.clientCancelPlanSubscriptionToAuthor,
|
||||
planName: 'Test Plan',
|
||||
@@ -808,26 +919,32 @@ void main() {
|
||||
expect(find.byType(NotificationWidget), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('displays ClientCancelPlanSubscriptionToSubscriberNotification', (tester) async {
|
||||
final notification = ClientCancelPlanSubscriptionToSubscriberNotification(
|
||||
type: NotificationType.clientCancelPlanSubscriptionToSubscriber,
|
||||
planName: 'Test Plan',
|
||||
planId: 1,
|
||||
authorName: '',
|
||||
authorId: 0,
|
||||
authorPhotoPath: '',
|
||||
chatUrl: '',
|
||||
isRead: false,
|
||||
createdAt: testDate,
|
||||
);
|
||||
testWidgets(
|
||||
'displays ClientCancelPlanSubscriptionToSubscriberNotification',
|
||||
(tester) async {
|
||||
final notification =
|
||||
ClientCancelPlanSubscriptionToSubscriberNotification(
|
||||
type: NotificationType.clientCancelPlanSubscriptionToSubscriber,
|
||||
planName: 'Test Plan',
|
||||
planId: 1,
|
||||
authorName: '',
|
||||
authorId: 0,
|
||||
authorPhotoPath: '',
|
||||
chatUrl: '',
|
||||
isRead: false,
|
||||
createdAt: testDate,
|
||||
);
|
||||
|
||||
await tester.pumpWidget(createTestWidget(notification));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.pumpWidget(createTestWidget(notification));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(NotificationWidget), findsOneWidget);
|
||||
});
|
||||
expect(find.byType(NotificationWidget), findsOneWidget);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('displays UserDailyReportCompletedNotification', (tester) async {
|
||||
testWidgets('displays UserDailyReportCompletedNotification', (
|
||||
tester,
|
||||
) async {
|
||||
final notification = UserDailyReportCompletedNotification(
|
||||
type: NotificationType.userDailyReportCompleted,
|
||||
chosenPlanId: 1,
|
||||
@@ -924,7 +1041,9 @@ void main() {
|
||||
expect(find.byType(NotificationWidget), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('displays ConsultantRequestStartedNotification', (tester) async {
|
||||
testWidgets('displays ConsultantRequestStartedNotification', (
|
||||
tester,
|
||||
) async {
|
||||
final notification = ConsultantRequestStartedNotification(
|
||||
type: NotificationType.consultantRequestStarted,
|
||||
consultantName: 'Test Consultant',
|
||||
@@ -938,7 +1057,9 @@ void main() {
|
||||
expect(find.byType(NotificationWidget), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('displays ClientCloseComplaintSuccessNotification', (tester) async {
|
||||
testWidgets('displays ClientCloseComplaintSuccessNotification', (
|
||||
tester,
|
||||
) async {
|
||||
final notification = ClientCloseComplaintSuccessNotification(
|
||||
type: NotificationType.clientCloseComplaintSuccess,
|
||||
planName: 'Test Plan',
|
||||
@@ -1032,7 +1153,9 @@ void main() {
|
||||
expect(find.byType(NotificationWidget), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('displays ProductStatusUpdatedForAuthorNotification', (tester) async {
|
||||
testWidgets('displays ProductStatusUpdatedForAuthorNotification', (
|
||||
tester,
|
||||
) async {
|
||||
final notification = ProductStatusUpdatedForAuthorNotification(
|
||||
type: NotificationType.productStatusUpdatedForAuthor,
|
||||
productName: 'Test Product',
|
||||
@@ -1065,7 +1188,9 @@ void main() {
|
||||
expect(find.byType(NotificationWidget), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('displays PersonalDietIsDefinedToCustomerNotification', (tester) async {
|
||||
testWidgets('displays PersonalDietIsDefinedToCustomerNotification', (
|
||||
tester,
|
||||
) async {
|
||||
final notification = PersonalDietIsDefinedToCustomerNotification(
|
||||
type: NotificationType.personalDietIsDefinedToCustomer,
|
||||
isRead: false,
|
||||
@@ -1128,35 +1253,3 @@ void main() {
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -7,8 +7,10 @@ import 'package:eatvio_app/features/feed/models/diets_pagination_model.dart';
|
||||
import 'package:eatvio_app/features/feed/models/plan_pagination_model.dart';
|
||||
import 'package:eatvio_app/features/order_info/cubit/order_info_cubit.dart';
|
||||
import 'package:eatvio_app/features/order_info/presentation/info_by_order_screen.dart';
|
||||
import 'package:eatvio_app/features/order_info/presentation/widgets/collect_charge_banner.dart';
|
||||
import 'package:eatvio_app/features/plans/enums/chosen_plan_state_enum.dart';
|
||||
import 'package:eatvio_app/features/plans/models/chosen_plan_model.dart';
|
||||
import 'package:eatvio_app/features/profile/models/profile_model.dart';
|
||||
import 'package:eatvio_app/features/profile/repositories/profile_repository.dart';
|
||||
import 'package:eatvio_app/generated/l10n.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
@@ -29,15 +31,24 @@ void main() {
|
||||
late OrderInfoCubit orderInfoCubit;
|
||||
|
||||
setUpAll(() async {
|
||||
dotenv.loadFromString(envString: 'DEV_BASE_URL=http://localhost\nBASE_URL=http://localhost');
|
||||
dotenv.loadFromString(
|
||||
envString: 'DEV_BASE_URL=http://localhost\nBASE_URL=http://localhost',
|
||||
);
|
||||
});
|
||||
|
||||
setUp(() async {
|
||||
await sl.reset();
|
||||
sl.registerLazySingleton<AppConfig>(() => AppConfig(buildType: AppBuildType.tests)..setBaseUrl('http://localhost'));
|
||||
sl.registerLazySingleton<AppConfig>(
|
||||
() =>
|
||||
AppConfig(buildType: AppBuildType.tests)
|
||||
..setBaseUrl('http://localhost'),
|
||||
);
|
||||
mockChatRepository = MockChatRepository();
|
||||
mockProfileRepository = MockProfileRepository();
|
||||
orderInfoCubit = OrderInfoCubit(chatRepository: mockChatRepository, profileRepository: mockProfileRepository);
|
||||
orderInfoCubit = OrderInfoCubit(
|
||||
chatRepository: mockChatRepository,
|
||||
profileRepository: mockProfileRepository,
|
||||
);
|
||||
sl.registerLazySingleton<OrderInfoCubit>(() => orderInfoCubit);
|
||||
});
|
||||
|
||||
@@ -45,7 +56,11 @@ void main() {
|
||||
orderInfoCubit.close();
|
||||
});
|
||||
|
||||
Widget createTestWidget({required int planId, bool? successfulPayment, String? customerSlug}) {
|
||||
Widget createTestWidget({
|
||||
required int planId,
|
||||
bool? successfulPayment,
|
||||
String? customerSlug,
|
||||
}) {
|
||||
return MaterialApp(
|
||||
theme: ThemeHelper().themeData(),
|
||||
localizationsDelegates: const [
|
||||
@@ -55,12 +70,20 @@ void main() {
|
||||
GlobalCupertinoLocalizations.delegate,
|
||||
],
|
||||
supportedLocales: const [Locale('ru')],
|
||||
home: InfoByOrderScreen(planId: planId, successfulPayment: successfulPayment, customerSlug: customerSlug),
|
||||
home: InfoByOrderScreen(
|
||||
planId: planId,
|
||||
successfulPayment: successfulPayment,
|
||||
customerSlug: customerSlug,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
testWidgets('displays screen with loading state initially', (WidgetTester tester) async {
|
||||
when(mockChatRepository.getChosenPlan(id: anyNamed('id'))).thenThrow(Exception('Not implemented'));
|
||||
testWidgets('displays screen with loading state initially', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
when(
|
||||
mockChatRepository.getChosenPlan(id: anyNamed('id')),
|
||||
).thenThrow(Exception('Not implemented'));
|
||||
|
||||
await tester.pumpWidget(createTestWidget(planId: 1));
|
||||
await tester.pump();
|
||||
@@ -69,8 +92,12 @@ void main() {
|
||||
expect(find.byType(Scaffold), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('calls getChosenPlan when screen is created', (WidgetTester tester) async {
|
||||
when(mockChatRepository.getChosenPlan(id: anyNamed('id'))).thenThrow(Exception('Not implemented'));
|
||||
testWidgets('calls getChosenPlan when screen is created', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
when(
|
||||
mockChatRepository.getChosenPlan(id: anyNamed('id')),
|
||||
).thenThrow(Exception('Not implemented'));
|
||||
|
||||
await tester.pumpWidget(createTestWidget(planId: 123));
|
||||
await tester.pump();
|
||||
@@ -78,18 +105,28 @@ void main() {
|
||||
verify(mockChatRepository.getChosenPlan(id: 123)).called(1);
|
||||
});
|
||||
|
||||
testWidgets('calls getChosenPlan when customerSlug is provided', (WidgetTester tester) async {
|
||||
when(mockChatRepository.getChosenPlan(id: anyNamed('id'))).thenThrow(Exception('Not implemented'));
|
||||
testWidgets('calls getChosenPlan when customerSlug is provided', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
when(
|
||||
mockChatRepository.getChosenPlan(id: anyNamed('id')),
|
||||
).thenThrow(Exception('Not implemented'));
|
||||
|
||||
await tester.pumpWidget(createTestWidget(planId: 123, customerSlug: 'test-user'));
|
||||
await tester.pumpWidget(
|
||||
createTestWidget(planId: 123, customerSlug: 'test-user'),
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
verify(mockChatRepository.getChosenPlan(id: 123)).called(1);
|
||||
// Note: getProfileBySlug is only called if getChosenPlan succeeds
|
||||
});
|
||||
|
||||
testWidgets('displays screen with back button', (WidgetTester tester) async {
|
||||
when(mockChatRepository.getChosenPlan(id: anyNamed('id'))).thenThrow(Exception('Not implemented'));
|
||||
testWidgets('displays screen with back button', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
when(
|
||||
mockChatRepository.getChosenPlan(id: anyNamed('id')),
|
||||
).thenThrow(Exception('Not implemented'));
|
||||
|
||||
await tester.pumpWidget(createTestWidget(planId: 1));
|
||||
await tester.pump();
|
||||
@@ -98,7 +135,9 @@ void main() {
|
||||
expect(find.byIcon(Icons.arrow_back), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('displays screen with loaded plan', (WidgetTester tester) async {
|
||||
testWidgets('displays screen with loaded plan', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
final chosenPlan = ChosenPlanModel(
|
||||
id: 1,
|
||||
optionType: 'options1',
|
||||
@@ -113,10 +152,20 @@ void main() {
|
||||
image: 'https://example.com/image.jpg',
|
||||
duration: 30,
|
||||
state: 'active',
|
||||
type: PlanType(fit: true, slim: false, behavior: false, wellness: false),
|
||||
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),
|
||||
author: Author(
|
||||
authorSlug: 'author',
|
||||
authorName: 'Author Name',
|
||||
authorProfileIcon: 'https://example.com/icon.jpg',
|
||||
authorIsMe: false,
|
||||
),
|
||||
totalLikes: 10,
|
||||
totalViews: 100,
|
||||
totalComments: 5,
|
||||
@@ -127,7 +176,9 @@ void main() {
|
||||
),
|
||||
);
|
||||
|
||||
when(mockChatRepository.getChosenPlan(id: anyNamed('id'))).thenAnswer((_) async => chosenPlan);
|
||||
when(
|
||||
mockChatRepository.getChosenPlan(id: anyNamed('id')),
|
||||
).thenAnswer((_) async => chosenPlan);
|
||||
|
||||
await mockNetworkImagesFor(() async {
|
||||
await tester.pumpWidget(createTestWidget(planId: 1));
|
||||
@@ -137,7 +188,9 @@ void main() {
|
||||
expect(find.byType(InfoByOrderScreen), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('displays screen with successful payment', (WidgetTester tester) async {
|
||||
testWidgets('displays screen with successful payment', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
final chosenPlan = ChosenPlanModel(
|
||||
id: 1,
|
||||
optionType: 'options1',
|
||||
@@ -152,10 +205,20 @@ void main() {
|
||||
image: 'https://example.com/image.jpg',
|
||||
duration: 30,
|
||||
state: 'active',
|
||||
type: PlanType(fit: true, slim: false, behavior: false, wellness: false),
|
||||
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),
|
||||
author: Author(
|
||||
authorSlug: 'author',
|
||||
authorName: 'Author Name',
|
||||
authorProfileIcon: 'https://example.com/icon.jpg',
|
||||
authorIsMe: false,
|
||||
),
|
||||
totalLikes: 10,
|
||||
totalViews: 100,
|
||||
totalComments: 5,
|
||||
@@ -166,14 +229,114 @@ void main() {
|
||||
),
|
||||
);
|
||||
|
||||
when(mockChatRepository.getChosenPlan(id: anyNamed('id'))).thenAnswer((_) async => chosenPlan);
|
||||
when(
|
||||
mockChatRepository.getChosenPlan(id: anyNamed('id')),
|
||||
).thenAnswer((_) async => chosenPlan);
|
||||
|
||||
await mockNetworkImagesFor(() async {
|
||||
await tester.pumpWidget(createTestWidget(planId: 1, successfulPayment: true));
|
||||
await tester.pumpWidget(
|
||||
createTestWidget(planId: 1, successfulPayment: true),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
});
|
||||
|
||||
expect(find.byType(InfoByOrderScreen), findsOneWidget);
|
||||
});
|
||||
|
||||
ChosenPlanModel donePlan() {
|
||||
return ChosenPlanModel(
|
||||
id: 1,
|
||||
optionType: 'options1',
|
||||
startDate: DateTime(2024, 1, 1),
|
||||
chosenPlanState: ChosenPlanStateEnum.stateDone,
|
||||
chatId: 'chat-1',
|
||||
consultantId: 1,
|
||||
plan: const PlanModel(
|
||||
id: 1,
|
||||
slug: 'test-plan',
|
||||
name: 'Test Plan',
|
||||
image: 'https://example.com/image.jpg',
|
||||
duration: 30,
|
||||
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,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
testWidgets(
|
||||
'shows collect charge banner for consultant when plan is done',
|
||||
(WidgetTester tester) async {
|
||||
const profile = ProfileModel(
|
||||
id: 2,
|
||||
name: 'Customer',
|
||||
email: 'customer@example.com',
|
||||
phoneConfirmationState: 'confirmed',
|
||||
isPublicProfile: true,
|
||||
isBlockedByMe: false,
|
||||
isBlockedByAuthor: false,
|
||||
isBlockedByUser: false,
|
||||
isEntitySubscribedByMe: false,
|
||||
slug: 'test-user',
|
||||
profilePhotoUrl: '/test.jpg',
|
||||
roles: ['user'],
|
||||
badges: [],
|
||||
subscriptions: [],
|
||||
subscribers: [],
|
||||
);
|
||||
|
||||
when(
|
||||
mockChatRepository.getChosenPlan(id: anyNamed('id')),
|
||||
).thenAnswer((_) async => donePlan());
|
||||
when(
|
||||
mockProfileRepository.getProfileBySlug('test-user'),
|
||||
).thenAnswer((_) async => profile);
|
||||
|
||||
await mockNetworkImagesFor(() async {
|
||||
await tester.pumpWidget(
|
||||
createTestWidget(planId: 1, customerSlug: 'test-user'),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
});
|
||||
|
||||
expect(find.byType(CollectChargeBanner), findsOneWidget);
|
||||
expect(find.text('Получить выплату'), findsOneWidget);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('hides collect charge banner for client when plan is done', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
when(
|
||||
mockChatRepository.getChosenPlan(id: anyNamed('id')),
|
||||
).thenAnswer((_) async => donePlan());
|
||||
|
||||
await mockNetworkImagesFor(() async {
|
||||
await tester.pumpWidget(createTestWidget(planId: 1));
|
||||
await tester.pumpAndSettle();
|
||||
});
|
||||
|
||||
expect(find.byType(CollectChargeBanner), findsNothing);
|
||||
expect(find.text('Получить выплату'), findsNothing);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -6,8 +6,10 @@ import 'package:eatvio_app/core/widgets/custom_outlined_button.dart';
|
||||
import 'package:eatvio_app/features/order_info/presentation/widgets/collect_charge_banner.dart';
|
||||
import 'package:eatvio_app/features/plans/enums/chosen_plan_state_enum.dart';
|
||||
import 'package:eatvio_app/features/plans/models/chosen_plan_model.dart';
|
||||
import 'package:eatvio_app/features/feed/models/plan_pagination_model.dart' show PlanModel, PlanType;
|
||||
import 'package:eatvio_app/features/feed/models/diets_pagination_model.dart' show Author;
|
||||
import 'package:eatvio_app/features/feed/models/plan_pagination_model.dart'
|
||||
show PlanModel, PlanType;
|
||||
import 'package:eatvio_app/features/feed/models/diets_pagination_model.dart'
|
||||
show Author;
|
||||
import 'package:eatvio_app/generated/l10n.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_localizations/flutter_localizations.dart';
|
||||
@@ -20,8 +22,14 @@ void main() {
|
||||
group('CollectChargeBanner', () {
|
||||
setUp(() async {
|
||||
await sl.reset();
|
||||
dotenv.loadFromString(envString: 'DEV_BASE_URL=http://localhost\nBASE_URL=http://localhost');
|
||||
sl.registerLazySingleton<AppConfig>(() => AppConfig(buildType: AppBuildType.tests)..setBaseUrl('http://localhost'));
|
||||
dotenv.loadFromString(
|
||||
envString: 'DEV_BASE_URL=http://localhost\nBASE_URL=http://localhost',
|
||||
);
|
||||
sl.registerLazySingleton<AppConfig>(
|
||||
() =>
|
||||
AppConfig(buildType: AppBuildType.tests)
|
||||
..setBaseUrl('http://localhost'),
|
||||
);
|
||||
});
|
||||
|
||||
Widget createTestWidget(Widget child) {
|
||||
@@ -42,12 +50,76 @@ void main() {
|
||||
child: Scaffold(body: SingleChildScrollView(child: child)),
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/card-settings-screen',
|
||||
builder: (context, state) =>
|
||||
Scaffold(body: Text('chosenPlanId:${state.extra}')),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
testWidgets('should display collect charge banner with plan name and price', (WidgetTester tester) async {
|
||||
testWidgets(
|
||||
'should display collect charge banner with plan name and price',
|
||||
(WidgetTester tester) async {
|
||||
// Arrange
|
||||
final chosenPlan = ChosenPlanModel(
|
||||
id: 1,
|
||||
optionType: 'standard',
|
||||
startDate: DateTime.now(),
|
||||
chosenPlanState: ChosenPlanStateEnum.stateDone,
|
||||
chatId: 'chat-1',
|
||||
consultantId: 1,
|
||||
plan: const PlanModel(
|
||||
id: 1,
|
||||
slug: 'test-plan',
|
||||
name: 'Test Plan',
|
||||
image: 'test.jpg',
|
||||
duration: 30,
|
||||
state: 'active',
|
||||
type: PlanType(
|
||||
fit: true,
|
||||
slim: false,
|
||||
behavior: false,
|
||||
wellness: false,
|
||||
),
|
||||
details: 'Test details',
|
||||
packetsCount: 1,
|
||||
author: Author(
|
||||
authorSlug: '',
|
||||
authorName: '',
|
||||
authorProfileIcon: '',
|
||||
authorIsMe: false,
|
||||
),
|
||||
totalLikes: 0,
|
||||
totalViews: 0,
|
||||
totalComments: 0,
|
||||
isFavorite: false,
|
||||
minPriceOfOptions: 100.0,
|
||||
maxPriceOfOptions: 500.0,
|
||||
wasLikedByMe: false,
|
||||
),
|
||||
);
|
||||
const price = 1000.0;
|
||||
final widget = CollectChargeBanner(
|
||||
chosenPlan: chosenPlan,
|
||||
price: price,
|
||||
);
|
||||
|
||||
// Act
|
||||
await tester.pumpWidget(createTestWidget(widget));
|
||||
await tester.pump();
|
||||
|
||||
// Assert
|
||||
expect(find.byType(CollectChargeBanner), findsOneWidget);
|
||||
expect(find.byType(CustomOutlinedButton), findsOneWidget);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('should have button that navigates to card settings', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
// Arrange
|
||||
final chosenPlan = ChosenPlanModel(
|
||||
id: 1,
|
||||
@@ -63,10 +135,20 @@ void main() {
|
||||
image: 'test.jpg',
|
||||
duration: 30,
|
||||
state: 'active',
|
||||
type: PlanType(fit: true, slim: false, behavior: false, wellness: false),
|
||||
type: PlanType(
|
||||
fit: true,
|
||||
slim: false,
|
||||
behavior: false,
|
||||
wellness: false,
|
||||
),
|
||||
details: 'Test details',
|
||||
packetsCount: 1,
|
||||
author: Author(authorSlug: '', authorName: '', authorProfileIcon: '', authorIsMe: false),
|
||||
author: Author(
|
||||
authorSlug: '',
|
||||
authorName: '',
|
||||
authorProfileIcon: '',
|
||||
authorIsMe: false,
|
||||
),
|
||||
totalLikes: 0,
|
||||
totalViews: 0,
|
||||
totalComments: 0,
|
||||
@@ -83,50 +165,10 @@ void main() {
|
||||
await tester.pumpWidget(createTestWidget(widget));
|
||||
await tester.pump();
|
||||
|
||||
// Assert
|
||||
expect(find.byType(CollectChargeBanner), findsOneWidget);
|
||||
expect(find.byType(CustomOutlinedButton), findsOneWidget);
|
||||
});
|
||||
await tester.tap(find.byType(CustomOutlinedButton));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
testWidgets('should have button that navigates to card settings', (WidgetTester tester) async {
|
||||
// Arrange
|
||||
final chosenPlan = ChosenPlanModel(
|
||||
id: 1,
|
||||
optionType: 'standard',
|
||||
startDate: DateTime.now(),
|
||||
chosenPlanState: ChosenPlanStateEnum.stateDone,
|
||||
chatId: 'chat-1',
|
||||
consultantId: 1,
|
||||
plan: const PlanModel(
|
||||
id: 1,
|
||||
slug: 'test-plan',
|
||||
name: 'Test Plan',
|
||||
image: 'test.jpg',
|
||||
duration: 30,
|
||||
state: 'active',
|
||||
type: PlanType(fit: true, slim: false, behavior: false, wellness: false),
|
||||
details: 'Test details',
|
||||
packetsCount: 1,
|
||||
author: Author(authorSlug: '', authorName: '', authorProfileIcon: '', authorIsMe: false),
|
||||
totalLikes: 0,
|
||||
totalViews: 0,
|
||||
totalComments: 0,
|
||||
isFavorite: false,
|
||||
minPriceOfOptions: 100.0,
|
||||
maxPriceOfOptions: 500.0,
|
||||
wasLikedByMe: false,
|
||||
),
|
||||
);
|
||||
const price = 1000.0;
|
||||
final widget = CollectChargeBanner(chosenPlan: chosenPlan, price: price);
|
||||
|
||||
// Act
|
||||
await tester.pumpWidget(createTestWidget(widget));
|
||||
await tester.pump();
|
||||
|
||||
// Assert - проверяем, что кнопка существует
|
||||
expect(find.byType(CustomOutlinedButton), findsOneWidget);
|
||||
expect(find.text('chosenPlanId:1'), findsOneWidget);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:eatvio_app/features/diets/repositories/diet_repository.dart';
|
||||
import 'package:eatvio_app/features/feed/models/diets_pagination_model.dart';
|
||||
import 'package:eatvio_app/features/feed/models/plan_pagination_model.dart';
|
||||
import 'package:eatvio_app/features/plans/enums/chosen_plan_state_enum.dart';
|
||||
@@ -6,22 +7,36 @@ import 'package:eatvio_app/features/plans/models/chosen_plan_model.dart';
|
||||
import 'package:eatvio_app/features/plans/presentation/cubit/about_plan_cubit.dart';
|
||||
import 'package:eatvio_app/features/plans/presentation/cubit/about_plan_state.dart';
|
||||
import 'package:eatvio_app/features/plans/repositories/plan_repository.dart';
|
||||
import 'package:eatvio_app/features/profile/models/chosen_diets_pagination_model.dart';
|
||||
import 'package:eatvio_app/features/profile/repositories/profile_repository.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:mockito/annotations.dart';
|
||||
import 'package:mockito/mockito.dart';
|
||||
|
||||
import 'about_plan_cubit_test.mocks.dart';
|
||||
|
||||
@GenerateNiceMocks([MockSpec<PlanRepository>()])
|
||||
@GenerateNiceMocks([MockSpec<PlanRepository>(), MockSpec<ProfileRepository>(), MockSpec<DietRepository>()])
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
group('AboutPlanCubit', () {
|
||||
AboutPlanCubit? aboutPlanCubit;
|
||||
late MockPlanRepository mockPlanRepository;
|
||||
late MockProfileRepository mockProfileRepository;
|
||||
late MockDietRepository mockDietRepository;
|
||||
|
||||
AboutPlanCubit createCubit() {
|
||||
return AboutPlanCubit(
|
||||
planRepository: mockPlanRepository,
|
||||
profileRepository: mockProfileRepository,
|
||||
dietRepository: mockDietRepository,
|
||||
);
|
||||
}
|
||||
|
||||
setUp(() {
|
||||
mockPlanRepository = MockPlanRepository();
|
||||
mockProfileRepository = MockProfileRepository();
|
||||
mockDietRepository = MockDietRepository();
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
@@ -32,7 +47,7 @@ void main() {
|
||||
});
|
||||
|
||||
test('начальное состояние - initial', () {
|
||||
aboutPlanCubit = AboutPlanCubit(planRepository: mockPlanRepository);
|
||||
aboutPlanCubit = createCubit();
|
||||
|
||||
expect(aboutPlanCubit!.state, isA<AboutPlanState>());
|
||||
expect(aboutPlanCubit!.state.isLoading, isFalse);
|
||||
@@ -40,7 +55,7 @@ void main() {
|
||||
});
|
||||
|
||||
test('getPlan - успешная загрузка плана', () async {
|
||||
aboutPlanCubit = AboutPlanCubit(planRepository: mockPlanRepository);
|
||||
aboutPlanCubit = createCubit();
|
||||
|
||||
const testPlan = PlanModel(
|
||||
id: 1,
|
||||
@@ -79,7 +94,7 @@ void main() {
|
||||
});
|
||||
|
||||
test('getPlanWithLoading - устанавливает loading состояние', () async {
|
||||
aboutPlanCubit = AboutPlanCubit(planRepository: mockPlanRepository);
|
||||
aboutPlanCubit = createCubit();
|
||||
|
||||
const testPlan = PlanModel(
|
||||
id: 1,
|
||||
@@ -117,7 +132,7 @@ void main() {
|
||||
});
|
||||
|
||||
test('choseOption - изменяет выбранную опцию', () {
|
||||
aboutPlanCubit = AboutPlanCubit(planRepository: mockPlanRepository);
|
||||
aboutPlanCubit = createCubit();
|
||||
|
||||
aboutPlanCubit!.choseOption(2);
|
||||
|
||||
@@ -125,7 +140,7 @@ void main() {
|
||||
});
|
||||
|
||||
test('likePlan - лайк плана', () async {
|
||||
aboutPlanCubit = AboutPlanCubit(planRepository: mockPlanRepository);
|
||||
aboutPlanCubit = createCubit();
|
||||
|
||||
const testPlan = PlanModel(
|
||||
id: 1,
|
||||
@@ -166,7 +181,7 @@ void main() {
|
||||
});
|
||||
|
||||
test('getPlan - обработка ошибки', () async {
|
||||
aboutPlanCubit = AboutPlanCubit(planRepository: mockPlanRepository);
|
||||
aboutPlanCubit = createCubit();
|
||||
|
||||
when(mockPlanRepository.getPlan('test-plan')).thenThrow(Exception('Ошибка загрузки'));
|
||||
|
||||
@@ -177,7 +192,7 @@ void main() {
|
||||
});
|
||||
|
||||
test('getPlanWithLoading - обработка ошибки', () async {
|
||||
aboutPlanCubit = AboutPlanCubit(planRepository: mockPlanRepository);
|
||||
aboutPlanCubit = createCubit();
|
||||
|
||||
when(mockPlanRepository.getPlan('test-plan')).thenThrow(Exception('Ошибка загрузки'));
|
||||
|
||||
@@ -188,7 +203,7 @@ void main() {
|
||||
});
|
||||
|
||||
test('likePlan - убирает лайк если уже лайкнуто', () async {
|
||||
aboutPlanCubit = AboutPlanCubit(planRepository: mockPlanRepository);
|
||||
aboutPlanCubit = createCubit();
|
||||
|
||||
const testPlan = PlanModel(
|
||||
id: 1,
|
||||
@@ -231,7 +246,7 @@ void main() {
|
||||
});
|
||||
|
||||
test('addToFavorite - добавляет план в избранное', () async {
|
||||
aboutPlanCubit = AboutPlanCubit(planRepository: mockPlanRepository);
|
||||
aboutPlanCubit = createCubit();
|
||||
|
||||
const testPlan = PlanModel(
|
||||
id: 1,
|
||||
@@ -273,7 +288,7 @@ void main() {
|
||||
});
|
||||
|
||||
test('removeFromFavorite - удаляет план из избранного', () async {
|
||||
aboutPlanCubit = AboutPlanCubit(planRepository: mockPlanRepository);
|
||||
aboutPlanCubit = createCubit();
|
||||
|
||||
const testPlan = PlanModel(
|
||||
id: 1,
|
||||
@@ -315,7 +330,7 @@ void main() {
|
||||
});
|
||||
|
||||
test('chosePlan - успешно выбирает план', () async {
|
||||
aboutPlanCubit = AboutPlanCubit(planRepository: mockPlanRepository);
|
||||
aboutPlanCubit = createCubit();
|
||||
|
||||
const testPlan = PlanModel(
|
||||
id: 1,
|
||||
@@ -368,7 +383,7 @@ void main() {
|
||||
});
|
||||
|
||||
test('chosePlan - возвращает ошибку hasPlan при 400 статусе', () async {
|
||||
aboutPlanCubit = AboutPlanCubit(planRepository: mockPlanRepository);
|
||||
aboutPlanCubit = createCubit();
|
||||
|
||||
const testPlan = PlanModel(
|
||||
id: 1,
|
||||
@@ -415,7 +430,7 @@ void main() {
|
||||
});
|
||||
|
||||
test('likePlan - лайк плана с index', () async {
|
||||
aboutPlanCubit = AboutPlanCubit(planRepository: mockPlanRepository);
|
||||
aboutPlanCubit = createCubit();
|
||||
|
||||
const testPlan = PlanModel(
|
||||
id: 1,
|
||||
@@ -458,7 +473,7 @@ void main() {
|
||||
});
|
||||
|
||||
test('likePlan - убирает лайк с index', () async {
|
||||
aboutPlanCubit = AboutPlanCubit(planRepository: mockPlanRepository);
|
||||
aboutPlanCubit = createCubit();
|
||||
|
||||
const testPlan = PlanModel(
|
||||
id: 1,
|
||||
@@ -501,7 +516,7 @@ void main() {
|
||||
});
|
||||
|
||||
test('likePlan - обрабатывает ошибку', () async {
|
||||
aboutPlanCubit = AboutPlanCubit(planRepository: mockPlanRepository);
|
||||
aboutPlanCubit = createCubit();
|
||||
|
||||
const testPlan = PlanModel(
|
||||
id: 1,
|
||||
@@ -542,7 +557,7 @@ void main() {
|
||||
});
|
||||
|
||||
test('likePlan - не делает ничего если план не загружен', () async {
|
||||
aboutPlanCubit = AboutPlanCubit(planRepository: mockPlanRepository);
|
||||
aboutPlanCubit = createCubit();
|
||||
|
||||
await aboutPlanCubit!.likePlan(index: null, slug: 'test-plan');
|
||||
|
||||
@@ -551,7 +566,7 @@ void main() {
|
||||
});
|
||||
|
||||
test('addToFavorite - добавляет план в избранное с index', () async {
|
||||
aboutPlanCubit = AboutPlanCubit(planRepository: mockPlanRepository);
|
||||
aboutPlanCubit = createCubit();
|
||||
|
||||
const testPlan = PlanModel(
|
||||
id: 1,
|
||||
@@ -592,7 +607,7 @@ void main() {
|
||||
});
|
||||
|
||||
test('addToFavorite - обрабатывает ошибку', () async {
|
||||
aboutPlanCubit = AboutPlanCubit(planRepository: mockPlanRepository);
|
||||
aboutPlanCubit = createCubit();
|
||||
|
||||
const testPlan = PlanModel(
|
||||
id: 1,
|
||||
@@ -632,7 +647,7 @@ void main() {
|
||||
});
|
||||
|
||||
test('addToFavorite - не делает ничего если план не загружен', () async {
|
||||
aboutPlanCubit = AboutPlanCubit(planRepository: mockPlanRepository);
|
||||
aboutPlanCubit = createCubit();
|
||||
|
||||
await aboutPlanCubit!.addToFavorite(slug: 'test-plan', index: null);
|
||||
|
||||
@@ -641,7 +656,7 @@ void main() {
|
||||
});
|
||||
|
||||
test('removeFromFavorite - удаляет план из избранного с index', () async {
|
||||
aboutPlanCubit = AboutPlanCubit(planRepository: mockPlanRepository);
|
||||
aboutPlanCubit = createCubit();
|
||||
|
||||
const testPlan = PlanModel(
|
||||
id: 1,
|
||||
@@ -682,7 +697,7 @@ void main() {
|
||||
});
|
||||
|
||||
test('removeFromFavorite - обрабатывает ошибку', () async {
|
||||
aboutPlanCubit = AboutPlanCubit(planRepository: mockPlanRepository);
|
||||
aboutPlanCubit = createCubit();
|
||||
|
||||
const testPlan = PlanModel(
|
||||
id: 1,
|
||||
@@ -722,7 +737,7 @@ void main() {
|
||||
});
|
||||
|
||||
test('removeFromFavorite - не делает ничего если план не загружен', () async {
|
||||
aboutPlanCubit = AboutPlanCubit(planRepository: mockPlanRepository);
|
||||
aboutPlanCubit = createCubit();
|
||||
|
||||
await aboutPlanCubit!.removeFromFavorite(slug: 'test-plan', index: null);
|
||||
|
||||
@@ -731,7 +746,7 @@ void main() {
|
||||
});
|
||||
|
||||
test('chosePlan - выбирает план с optionIndex 1', () async {
|
||||
aboutPlanCubit = AboutPlanCubit(planRepository: mockPlanRepository);
|
||||
aboutPlanCubit = createCubit();
|
||||
|
||||
const testPlan = PlanModel(
|
||||
id: 1,
|
||||
@@ -784,7 +799,7 @@ void main() {
|
||||
});
|
||||
|
||||
test('chosePlan - выбирает план с optionIndex 2', () async {
|
||||
aboutPlanCubit = AboutPlanCubit(planRepository: mockPlanRepository);
|
||||
aboutPlanCubit = createCubit();
|
||||
|
||||
const testPlan = PlanModel(
|
||||
id: 1,
|
||||
@@ -837,7 +852,7 @@ void main() {
|
||||
});
|
||||
|
||||
test('chosePlan - возвращает пустой результат когда slug == null', () async {
|
||||
aboutPlanCubit = AboutPlanCubit(planRepository: mockPlanRepository);
|
||||
aboutPlanCubit = createCubit();
|
||||
|
||||
final result = await aboutPlanCubit!.chosePlan();
|
||||
|
||||
@@ -847,7 +862,7 @@ void main() {
|
||||
});
|
||||
|
||||
test('chosePlan - обрабатывает DioException с другим статусом', () async {
|
||||
aboutPlanCubit = AboutPlanCubit(planRepository: mockPlanRepository);
|
||||
aboutPlanCubit = createCubit();
|
||||
|
||||
const testPlan = PlanModel(
|
||||
id: 1,
|
||||
@@ -894,7 +909,7 @@ void main() {
|
||||
});
|
||||
|
||||
test('chosePlan - обрабатывает общее исключение', () async {
|
||||
aboutPlanCubit = AboutPlanCubit(planRepository: mockPlanRepository);
|
||||
aboutPlanCubit = createCubit();
|
||||
|
||||
const testPlan = PlanModel(
|
||||
id: 1,
|
||||
@@ -936,7 +951,7 @@ void main() {
|
||||
});
|
||||
|
||||
test('getPlanWithLoading - устанавливает isLoading в true перед загрузкой', () async {
|
||||
aboutPlanCubit = AboutPlanCubit(planRepository: mockPlanRepository);
|
||||
aboutPlanCubit = createCubit();
|
||||
|
||||
const testPlan = PlanModel(
|
||||
id: 1,
|
||||
@@ -977,5 +992,76 @@ void main() {
|
||||
await future;
|
||||
expect(aboutPlanCubit!.state.isLoading, isFalse);
|
||||
});
|
||||
|
||||
test('getActivePublicDiet - возвращает активную публичную диету', () async {
|
||||
aboutPlanCubit = createCubit();
|
||||
final publicDiet = ChosenDietModel(
|
||||
chosenDietID: 1,
|
||||
chosenPlanId: 0,
|
||||
state: 'started',
|
||||
startDate: DateTime(2024, 1, 1),
|
||||
dietId: 10,
|
||||
);
|
||||
when(mockProfileRepository.getChosenDiets()).thenAnswer((_) async => [publicDiet]);
|
||||
|
||||
final result = await aboutPlanCubit!.getActivePublicDiet();
|
||||
|
||||
expect(result, isNotNull);
|
||||
expect(result!.dietId, 10);
|
||||
expect(result.chosenPlanId, 0);
|
||||
});
|
||||
|
||||
test('getActivePublicDiet - игнорирует диету, привязанную к плану', () async {
|
||||
aboutPlanCubit = createCubit();
|
||||
final personalDiet = ChosenDietModel(
|
||||
chosenDietID: 1,
|
||||
chosenPlanId: 5,
|
||||
state: 'started',
|
||||
startDate: DateTime(2024, 1, 1),
|
||||
dietId: 10,
|
||||
);
|
||||
when(mockProfileRepository.getChosenDiets()).thenAnswer((_) async => [personalDiet]);
|
||||
|
||||
final result = await aboutPlanCubit!.getActivePublicDiet();
|
||||
|
||||
expect(result, isNull);
|
||||
});
|
||||
|
||||
test('getActivePublicDiet - возвращает null если нет выбранных диет', () async {
|
||||
aboutPlanCubit = createCubit();
|
||||
when(mockProfileRepository.getChosenDiets()).thenAnswer((_) async => []);
|
||||
|
||||
final result = await aboutPlanCubit!.getActivePublicDiet();
|
||||
|
||||
expect(result, isNull);
|
||||
});
|
||||
|
||||
test('getActivePublicDiet - возвращает null при ошибке', () async {
|
||||
aboutPlanCubit = createCubit();
|
||||
when(mockProfileRepository.getChosenDiets()).thenThrow(Exception('error'));
|
||||
|
||||
final result = await aboutPlanCubit!.getActivePublicDiet();
|
||||
|
||||
expect(result, isNull);
|
||||
});
|
||||
|
||||
test('cancelPublicDiet - успешно отменяет диету', () async {
|
||||
aboutPlanCubit = createCubit();
|
||||
when(mockDietRepository.cancelDiet(10)).thenAnswer((_) async {});
|
||||
|
||||
final result = await aboutPlanCubit!.cancelPublicDiet(10);
|
||||
|
||||
expect(result, isTrue);
|
||||
verify(mockDietRepository.cancelDiet(10)).called(1);
|
||||
});
|
||||
|
||||
test('cancelPublicDiet - возвращает false при ошибке', () async {
|
||||
aboutPlanCubit = createCubit();
|
||||
when(mockDietRepository.cancelDiet(10)).thenThrow(Exception('error'));
|
||||
|
||||
final result = await aboutPlanCubit!.cancelPublicDiet(10);
|
||||
|
||||
expect(result, isFalse);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -11,6 +11,8 @@ import 'package:eatvio_app/features/feed/presentation/bloc/feed_cubit/feed_cubit
|
||||
import 'package:eatvio_app/features/plans/presentation/cubit/about_plan_cubit.dart';
|
||||
import 'package:eatvio_app/features/plans/presentation/cubit/about_plan_state.dart';
|
||||
import 'package:eatvio_app/features/plans/presentation/pages/about_plan_screen.dart';
|
||||
import 'package:eatvio_app/features/plans/presentation/widgets/confirm_start_plan_bottom_sheet.dart';
|
||||
import 'package:eatvio_app/features/profile/models/chosen_diets_pagination_model.dart';
|
||||
import 'package:eatvio_app/generated/l10n.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
@@ -301,5 +303,82 @@ void main() {
|
||||
expect(find.byType(AboutPlanScreen), findsOneWidget);
|
||||
expect(find.text('Test Plan'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('order with public diet shows start plan confirmation', (tester) async {
|
||||
final planCubit = MockAboutPlanCubit();
|
||||
final feedCubit = MockFeedCubit();
|
||||
final commentsCubit = MockCommentsCubit();
|
||||
const state = AboutPlanState(isLoading: false, planModel: testPlan, chosenOptionIndex: 0, error: null);
|
||||
final publicDiet = ChosenDietModel(
|
||||
chosenDietID: 1,
|
||||
chosenPlanId: 0,
|
||||
state: 'started',
|
||||
startDate: DateTime(2024, 1, 1),
|
||||
dietId: 10,
|
||||
);
|
||||
|
||||
when(planCubit.state).thenReturn(state);
|
||||
when(planCubit.stream).thenAnswer((_) => const Stream<AboutPlanState>.empty());
|
||||
when(planCubit.close()).thenAnswer((_) async => Future<void>.value());
|
||||
when(planCubit.getActivePublicDiet()).thenAnswer((_) async => publicDiet);
|
||||
when(feedCubit.viewPlan(any)).thenAnswer((_) async => Future<void>.value());
|
||||
when(feedCubit.stream).thenAnswer((_) => const Stream.empty());
|
||||
when(feedCubit.close()).thenAnswer((_) async => Future<void>.value());
|
||||
when(commentsCubit.state).thenReturn(const CommentsState());
|
||||
when(commentsCubit.stream).thenAnswer((_) => const Stream<CommentsState>.empty());
|
||||
when(commentsCubit.close()).thenAnswer((_) async => Future<void>.value());
|
||||
when(commentsCubit.getComments(any, any, any)).thenAnswer((_) async => Future<void>.value());
|
||||
|
||||
registerMocks(planCubit, commentsCubit);
|
||||
|
||||
await mockNetworkImagesFor(() async {
|
||||
await tester.pumpWidget(wrap(planCubit, feedCubit, commentsCubit, const AboutPlanScreen(slug: 'test', navigateToComments: false, index: null, highlightedCommentId: null)));
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
});
|
||||
|
||||
await tester.tap(find.text('Заказать'));
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
|
||||
expect(find.byType(ConfirmStartPlanBottomSheet), findsOneWidget);
|
||||
expect(find.text('Вы уверены, что хотите начать прохождение плана?'), findsOneWidget);
|
||||
verifyNever(planCubit.chosePlan());
|
||||
});
|
||||
|
||||
testWidgets('order without public diet calls chosePlan', (tester) async {
|
||||
final planCubit = MockAboutPlanCubit();
|
||||
final feedCubit = MockFeedCubit();
|
||||
final commentsCubit = MockCommentsCubit();
|
||||
const state = AboutPlanState(isLoading: false, planModel: testPlan, chosenOptionIndex: 0, error: null);
|
||||
|
||||
when(planCubit.state).thenReturn(state);
|
||||
when(planCubit.stream).thenAnswer((_) => const Stream<AboutPlanState>.empty());
|
||||
when(planCubit.close()).thenAnswer((_) async => Future<void>.value());
|
||||
when(planCubit.getActivePublicDiet()).thenAnswer((_) async => null);
|
||||
when(planCubit.chosePlan()).thenAnswer((_) async => (null, ''));
|
||||
when(feedCubit.viewPlan(any)).thenAnswer((_) async => Future<void>.value());
|
||||
when(feedCubit.stream).thenAnswer((_) => const Stream.empty());
|
||||
when(feedCubit.close()).thenAnswer((_) async => Future<void>.value());
|
||||
when(commentsCubit.state).thenReturn(const CommentsState());
|
||||
when(commentsCubit.stream).thenAnswer((_) => const Stream<CommentsState>.empty());
|
||||
when(commentsCubit.close()).thenAnswer((_) async => Future<void>.value());
|
||||
when(commentsCubit.getComments(any, any, any)).thenAnswer((_) async => Future<void>.value());
|
||||
|
||||
registerMocks(planCubit, commentsCubit);
|
||||
|
||||
await mockNetworkImagesFor(() async {
|
||||
await tester.pumpWidget(wrap(planCubit, feedCubit, commentsCubit, const AboutPlanScreen(slug: 'test', navigateToComments: false, index: null, highlightedCommentId: null)));
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
});
|
||||
|
||||
await tester.tap(find.text('Заказать'));
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
|
||||
expect(find.byType(ConfirmStartPlanBottomSheet), findsNothing);
|
||||
verify(planCubit.chosePlan()).called(1);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
||||
import 'package:eatvio_app/core/widgets/custom_outlined_button.dart';
|
||||
import 'package:eatvio_app/features/plans/presentation/widgets/confirm_start_plan_bottom_sheet.dart';
|
||||
import 'package:eatvio_app/generated/l10n.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_localizations/flutter_localizations.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
group('ConfirmStartPlanBottomSheet', () {
|
||||
Widget createTestWidget(Widget child) {
|
||||
return MaterialApp(
|
||||
theme: ThemeHelper().themeData(),
|
||||
localizationsDelegates: const [
|
||||
S.delegate,
|
||||
GlobalMaterialLocalizations.delegate,
|
||||
GlobalWidgetsLocalizations.delegate,
|
||||
GlobalCupertinoLocalizations.delegate,
|
||||
],
|
||||
supportedLocales: const [Locale('ru')],
|
||||
home: Scaffold(body: child),
|
||||
);
|
||||
}
|
||||
|
||||
testWidgets('показывает текст подтверждения начала плана', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
createTestWidget(
|
||||
ConfirmStartPlanBottomSheet(onConfirm: () async {}),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text('Вы уверены, что хотите начать прохождение плана?'), findsOneWidget);
|
||||
expect(find.byType(CustomOutlinedButton), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('по нажатию подтверждения вызывает onConfirm', (tester) async {
|
||||
var confirmed = false;
|
||||
await tester.pumpWidget(
|
||||
createTestWidget(
|
||||
ConfirmStartPlanBottomSheet(
|
||||
onConfirm: () async {
|
||||
confirmed = true;
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
await tester.tap(find.byType(CustomOutlinedButton));
|
||||
await tester.pump();
|
||||
|
||||
expect(confirmed, isTrue);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -3,10 +3,12 @@ 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/animated_option_button.dart';
|
||||
import 'package:eatvio_app/features/diets/repositories/diet_repository.dart';
|
||||
import 'package:eatvio_app/features/plans/presentation/cubit/about_plan_cubit.dart';
|
||||
import 'package:eatvio_app/features/plans/presentation/cubit/about_plan_state.dart';
|
||||
import 'package:eatvio_app/features/plans/presentation/widgets/options_tab.dart';
|
||||
import 'package:eatvio_app/features/plans/repositories/plan_repository.dart';
|
||||
import 'package:eatvio_app/features/profile/repositories/profile_repository.dart';
|
||||
import 'package:eatvio_app/generated/l10n.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
@@ -18,17 +20,25 @@ import 'package:flutter_dotenv/flutter_dotenv.dart';
|
||||
|
||||
import 'widgets_test.mocks.dart';
|
||||
|
||||
@GenerateNiceMocks([MockSpec<PlanRepository>()])
|
||||
@GenerateNiceMocks([MockSpec<PlanRepository>(), MockSpec<ProfileRepository>(), MockSpec<DietRepository>()])
|
||||
void main() {
|
||||
group('Plans Widgets Tests', () {
|
||||
late MockPlanRepository mockPlanRepository;
|
||||
late MockProfileRepository mockProfileRepository;
|
||||
late MockDietRepository mockDietRepository;
|
||||
late AboutPlanCubit aboutPlanCubit;
|
||||
|
||||
setUp(() async {
|
||||
await sl.reset();
|
||||
dotenv.loadFromString(envString: 'DEV_BASE_URL=http://localhost\nBASE_URL=http://localhost');
|
||||
mockPlanRepository = MockPlanRepository();
|
||||
aboutPlanCubit = AboutPlanCubit(planRepository: mockPlanRepository);
|
||||
mockProfileRepository = MockProfileRepository();
|
||||
mockDietRepository = MockDietRepository();
|
||||
aboutPlanCubit = AboutPlanCubit(
|
||||
planRepository: mockPlanRepository,
|
||||
profileRepository: mockProfileRepository,
|
||||
dietRepository: mockDietRepository,
|
||||
);
|
||||
sl.registerLazySingleton<AppConfig>(() => AppConfig(buildType: AppBuildType.tests)..setBaseUrl('http://localhost'));
|
||||
});
|
||||
|
||||
|
||||
@@ -100,6 +100,22 @@ void main() {
|
||||
expect(model.dietId, 5);
|
||||
});
|
||||
|
||||
test('creates ChosenDietModel from JSON with null chosen_plan_id as public diet', () {
|
||||
final json = {
|
||||
'id': 1,
|
||||
'chosen_plan_id': null,
|
||||
'state': 'started',
|
||||
'start_date': '2023-01-01T00:00:00Z',
|
||||
'diet': {'id': 5},
|
||||
};
|
||||
|
||||
final model = ChosenDietModel.fromJson(json);
|
||||
|
||||
expect(model.chosenDietID, 1);
|
||||
expect(model.chosenPlanId, 0);
|
||||
expect(model.dietId, 5);
|
||||
});
|
||||
|
||||
test('creates ChosenDietModel with null dietId', () {
|
||||
final model = ChosenDietModel(
|
||||
chosenDietID: 1,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user