forked from eatvio/eatvio_app
Compare commits
10 Commits
video_call
...
issue-1062
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1bbb08f2a2 | ||
|
|
ef4df92f58 | ||
|
|
dcd343e765 | ||
|
|
98c175df44 | ||
|
|
cb312037b2 | ||
|
|
f8e06b6d7a | ||
|
|
866ea2fe3b | ||
| d73f68e24b | |||
|
|
603a158cf2 | ||
| a1bae68307 |
@@ -8,8 +8,16 @@ jobs:
|
||||
build:
|
||||
runs-on: [macos-arm64]
|
||||
steps:
|
||||
# launchd does not load the login-shell PATH, so Homebrew git is invisible to act.
|
||||
- name: Add Homebrew to PATH
|
||||
run: |
|
||||
echo "/opt/homebrew/bin" >> "$GITHUB_PATH"
|
||||
echo "/opt/homebrew/sbin" >> "$GITHUB_PATH"
|
||||
|
||||
- name: Fastlane version
|
||||
run: fastlane --version
|
||||
run: |
|
||||
git --version
|
||||
fastlane --version
|
||||
|
||||
- name: Checkout
|
||||
uses: https://github.com/actions/checkout@v2
|
||||
|
||||
@@ -13,6 +13,9 @@ import io.flutter.plugin.common.MethodChannel
|
||||
import com.hiennv.flutter_callkit_incoming.FlutterCallkitIncomingPlugin
|
||||
import com.huawei.agconnect.config.AGConnectServicesConfig
|
||||
import com.huawei.hms.aaid.HmsInstanceId
|
||||
import com.huawei.hms.api.ConnectionResult
|
||||
import com.huawei.hms.api.HuaweiApiAvailability
|
||||
import com.huawei.hms.common.ApiException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -82,6 +85,20 @@ class MainActivity: FlutterActivity() {
|
||||
}
|
||||
methodChannel?.setMethodCallHandler { call, result ->
|
||||
when (call.method) {
|
||||
"isHmsAvailable" -> {
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
val available = try {
|
||||
HuaweiApiAvailability.getInstance()
|
||||
.isHuaweiMobileServicesAvailable(applicationContext) == ConnectionResult.SUCCESS
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.w("HMS", "HMS availability check failed: ${e.message}")
|
||||
false
|
||||
}
|
||||
runOnUiThread {
|
||||
result.success(available)
|
||||
}
|
||||
}
|
||||
}
|
||||
"getHuaweiToken" -> {
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
try {
|
||||
@@ -121,10 +138,30 @@ class MainActivity: FlutterActivity() {
|
||||
runOnUiThread {
|
||||
result.success(token)
|
||||
}
|
||||
} catch (e: ApiException) {
|
||||
if (e.statusCode == 907135003) {
|
||||
android.util.Log.w("HMS", "HMS client API invalid (${e.statusCode}), falling back to FCM")
|
||||
runOnUiThread {
|
||||
result.success(null)
|
||||
}
|
||||
} else {
|
||||
android.util.Log.e("HMS", "Error getting HMS token: ${e.statusCode}: ${e.message}", e)
|
||||
runOnUiThread {
|
||||
result.error("HMS_TOKEN_ERROR", "${e.statusCode}: ${e.message}", null)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.e("HMS", "Error getting HMS token: ${e.message}", e)
|
||||
runOnUiThread {
|
||||
result.error("HMS_TOKEN_ERROR", e.message, null)
|
||||
val message = e.message.orEmpty()
|
||||
if (message.contains("907135003") || message.contains("client api invalid", ignoreCase = true)) {
|
||||
android.util.Log.w("HMS", "HMS client API invalid, falling back to FCM")
|
||||
runOnUiThread {
|
||||
result.success(null)
|
||||
}
|
||||
} else {
|
||||
android.util.Log.e("HMS", "Error getting HMS token: $message", e)
|
||||
runOnUiThread {
|
||||
result.error("HMS_TOKEN_ERROR", e.message, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:eatvio_app/core/push/hms_client_error.dart';
|
||||
import 'package:sentry_flutter/sentry_flutter.dart';
|
||||
|
||||
/// Rules for which errors are sent to crash reporting.
|
||||
@@ -15,6 +16,7 @@ class ErrorReportPolicy {
|
||||
if (error == null) return false;
|
||||
if (error is String) return false;
|
||||
if (error is SocketException) return false;
|
||||
if (isHmsClientUnavailable(error)) return false;
|
||||
|
||||
if (error is DioException) {
|
||||
return _shouldReportDio(error);
|
||||
|
||||
@@ -4,14 +4,13 @@ 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:eatvio_app/core/push/hms_client_error.dart';
|
||||
import 'package:device_info_plus/device_info_plus.dart';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:eatvio_app/core/locator_service.dart';
|
||||
import 'package:eatvio_app/core/token_storage.dart';
|
||||
import 'package:firebase_messaging/firebase_messaging.dart';
|
||||
import 'package:flutter/services.dart' show MethodChannel, PlatformException;
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:eatvio_app/core/error_reporting/error_reporter.dart';
|
||||
import 'package:flutter_callkit_incoming/flutter_callkit_incoming.dart';
|
||||
|
||||
@@ -27,20 +26,8 @@ class FirebaseApi {
|
||||
_notificationPermissionRequester = notificationPermissionRequester ?? NotificationPermissionRequester();
|
||||
|
||||
Future<String?> getToken() async {
|
||||
String? token;
|
||||
|
||||
if (await _isHuaweiDevice()) {
|
||||
token = await _getHuaweiPushTokenNative().timeout(
|
||||
const Duration(seconds: 5),
|
||||
onTimeout: () => null,
|
||||
);
|
||||
} else {
|
||||
token = await _firebaseMessaging.getToken().timeout(
|
||||
const Duration(seconds: 5),
|
||||
onTimeout: () => null,
|
||||
);
|
||||
}
|
||||
return token;
|
||||
final credentials = await _resolvePushCredentials();
|
||||
return credentials.token;
|
||||
}
|
||||
|
||||
Future<void> _requestNotificationPermission() async {
|
||||
@@ -58,12 +45,15 @@ class FirebaseApi {
|
||||
try {
|
||||
await _requestNotificationPermission();
|
||||
final deviceInfo = DeviceInfoPlugin();
|
||||
final credentials = await _resolvePushCredentials();
|
||||
final token = credentials.token;
|
||||
if (token == null || token.isEmpty) return;
|
||||
await sl<Dio>().post(
|
||||
'/api/anonymous_token',
|
||||
data: {
|
||||
'platform': Platform.isIOS ? 'ios' : (await _isHuaweiDevice()) ? 'huawei' : 'android',
|
||||
'platform': credentials.platform,
|
||||
'hw_id': Platform.isIOS ? (await deviceInfo.iosInfo).identifierForVendor : (await deviceInfo.androidInfo).id,
|
||||
'token': await getToken(),
|
||||
'token': token,
|
||||
},
|
||||
).timeout(
|
||||
const Duration(seconds: 10),
|
||||
@@ -83,13 +73,15 @@ class FirebaseApi {
|
||||
try {
|
||||
await _requestNotificationPermission();
|
||||
final deviceInfo = DeviceInfoPlugin();
|
||||
final isHuawei = Platform.isAndroid ? await _isHuaweiDevice() : false;
|
||||
final credentials = await _resolvePushCredentials();
|
||||
final token = credentials.token;
|
||||
if (token == null || token.isEmpty) return;
|
||||
await sl<Dio>().post(
|
||||
'/api/profile/notification/token/register',
|
||||
data: {
|
||||
'platform': Platform.isIOS ? 'ios' : (isHuawei ? 'huawei' : 'android'),
|
||||
'platform': credentials.platform,
|
||||
'hw_id': Platform.isIOS ? (await deviceInfo.iosInfo).identifierForVendor : (await deviceInfo.androidInfo).id,
|
||||
'token': await getToken(),
|
||||
'token': token,
|
||||
},
|
||||
).timeout(
|
||||
const Duration(seconds: 10),
|
||||
@@ -124,13 +116,13 @@ class FirebaseApi {
|
||||
Future<void> unregisterPushToken() async {
|
||||
if (!await TokenStorage.haveTokenInStorage()) return;
|
||||
try {
|
||||
final isHuawei = Platform.isAndroid ? await _isHuaweiDevice() : false;
|
||||
final platform = Platform.isIOS ? 'ios' : (isHuawei ? 'huawei' : 'android');
|
||||
final token = (await getToken()) ?? '';
|
||||
final credentials = await _resolvePushCredentials();
|
||||
final token = credentials.token ?? '';
|
||||
if (token.isEmpty) return;
|
||||
await sl<Dio>().post(
|
||||
'/api/profile/notification/token/unregister',
|
||||
data: {
|
||||
'platform': platform,
|
||||
'platform': credentials.platform,
|
||||
'token': token,
|
||||
},
|
||||
).timeout(
|
||||
@@ -190,30 +182,74 @@ class FirebaseApi {
|
||||
}
|
||||
}
|
||||
|
||||
Future<({String? token, String platform})> _resolvePushCredentials() async {
|
||||
if (Platform.isIOS) {
|
||||
return (
|
||||
token: await _getFcmToken(),
|
||||
platform: pushPlatform(isIos: true, huaweiFamily: false, hmsAvailable: false, hasHmsToken: false),
|
||||
);
|
||||
}
|
||||
|
||||
final huaweiFamily = await _isHuaweiDevice();
|
||||
if (huaweiFamily && await _isHmsAvailable()) {
|
||||
final token = await _getHuaweiPushTokenNative().timeout(
|
||||
const Duration(seconds: 5),
|
||||
onTimeout: () => null,
|
||||
);
|
||||
if (token != null && token.isNotEmpty) {
|
||||
return (
|
||||
token: token,
|
||||
platform: pushPlatform(isIos: false, huaweiFamily: true, hmsAvailable: true, hasHmsToken: true),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
token: await _getFcmToken(),
|
||||
platform: pushPlatform(isIos: false, huaweiFamily: huaweiFamily, hmsAvailable: false, hasHmsToken: false),
|
||||
);
|
||||
}
|
||||
|
||||
Future<String?> _getFcmToken() {
|
||||
return _firebaseMessaging.getToken().timeout(
|
||||
const Duration(seconds: 5),
|
||||
onTimeout: () => null,
|
||||
);
|
||||
}
|
||||
|
||||
Future<bool> _isHuaweiDevice() async {
|
||||
if (!Platform.isAndroid) return false;
|
||||
try {
|
||||
final info = await DeviceInfoPlugin().androidInfo;
|
||||
final brand = info.brand.toLowerCase();
|
||||
final manufacturer = info.manufacturer.toLowerCase();
|
||||
return brand.contains('huawei') || brand.contains('honor') || manufacturer.contains('huawei') || manufacturer.contains('honor');
|
||||
return isHuaweiFamilyBrand(brand: info.brand, manufacturer: info.manufacturer);
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> _isHmsAvailable() async {
|
||||
try {
|
||||
const channel = MethodChannel('com.eatvioapp/hms_push');
|
||||
final available = await channel.invokeMethod<bool>('isHmsAvailable').timeout(const Duration(seconds: 5));
|
||||
return available ?? false;
|
||||
} catch (e, st) {
|
||||
log('HMS availability check failed: $e', stackTrace: st);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<String?> _getHuaweiPushTokenNative() async {
|
||||
try {
|
||||
const channel = MethodChannel('com.eatvioapp/hms_push');
|
||||
final String? token = await channel.invokeMethod<String>('getHuaweiToken');
|
||||
return token;
|
||||
} on PlatformException catch (e, st) {
|
||||
showErrorToast('$e $st');
|
||||
log(e.toString(), stackTrace: st);
|
||||
await _errorReporter.report(e, st, origin: 'FirebaseApi');
|
||||
if (!isHmsClientUnavailable(e)) {
|
||||
await _errorReporter.report(e, st, origin: 'FirebaseApi');
|
||||
}
|
||||
return null;
|
||||
} catch (e, st) {
|
||||
showErrorToast('$e $st');
|
||||
log(e.toString(), stackTrace: st);
|
||||
await _errorReporter.report(e, st, origin: 'FirebaseApi');
|
||||
return null;
|
||||
|
||||
36
lib/core/push/hms_client_error.dart
Normal file
36
lib/core/push/hms_client_error.dart
Normal file
@@ -0,0 +1,36 @@
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
bool isHuaweiFamilyBrand({
|
||||
required String brand,
|
||||
required String manufacturer,
|
||||
}) {
|
||||
final normalizedBrand = brand.toLowerCase();
|
||||
final normalizedManufacturer = manufacturer.toLowerCase();
|
||||
return normalizedBrand.contains('huawei') ||
|
||||
normalizedBrand.contains('honor') ||
|
||||
normalizedManufacturer.contains('huawei') ||
|
||||
normalizedManufacturer.contains('honor');
|
||||
}
|
||||
|
||||
/// HMS Core is missing or rejects this app.
|
||||
///
|
||||
/// Honor devices with Google services return `907135003: client api invalid`.
|
||||
/// That is a device capability, not an app crash: the caller should use FCM.
|
||||
bool isHmsClientUnavailable(Object error) {
|
||||
if (error is! PlatformException) return false;
|
||||
final message = '${error.message ?? ''} ${error.details ?? ''}'.toLowerCase();
|
||||
return message.contains('907135003') ||
|
||||
message.contains('client api invalid');
|
||||
}
|
||||
|
||||
/// `huawei` only when an HMS token was actually issued. Otherwise Android uses FCM.
|
||||
String pushPlatform({
|
||||
required bool isIos,
|
||||
required bool huaweiFamily,
|
||||
required bool hmsAvailable,
|
||||
required bool hasHmsToken,
|
||||
}) {
|
||||
if (isIos) return 'ios';
|
||||
if (huaweiFamily && hmsAvailable && hasHmsToken) return 'huawei';
|
||||
return 'android';
|
||||
}
|
||||
@@ -24,6 +24,7 @@ import 'package:eatvio_app/features/create_diet/presentation/diet_preview_screen
|
||||
import 'package:eatvio_app/features/create_plan/presentation/create_plan_screen.dart';
|
||||
import 'package:eatvio_app/features/create_recipe/presentation/pages/recipe_create_screen.dart';
|
||||
import 'package:eatvio_app/features/dev_tools/presentation/dev_tools_screen.dart';
|
||||
import 'package:eatvio_app/features/feed/models/plan_pagination_model.dart';
|
||||
import 'package:eatvio_app/features/feed/models/recipes_model.dart';
|
||||
import 'package:eatvio_app/features/glossary/presentation/pages/glossary_screen.dart';
|
||||
import 'package:eatvio_app/features/menu/presentation/pages/blocked_users_screen.dart';
|
||||
@@ -183,7 +184,14 @@ class OtherRoutes {
|
||||
return DietPreviewScreen(diet: diet, isImageInBase64: isImageInBase64);
|
||||
},
|
||||
),
|
||||
GoRoute(path: RouteConstants.createPlanScreen, name: 'createPlanScreen', builder: (context, state) => const CreatePlanScreen()),
|
||||
GoRoute(
|
||||
path: RouteConstants.createPlanScreen,
|
||||
name: 'createPlanScreen',
|
||||
builder: (context, state) {
|
||||
final plan = state.extra is PlanModel ? state.extra as PlanModel : null;
|
||||
return CreatePlanScreen(plan: plan);
|
||||
},
|
||||
),
|
||||
GoRoute(path: MyDietsScreen.path, name: MyDietsScreen.name, builder: (context, state) => const MyDietsScreen()),
|
||||
GoRoute(path: MyPlansScreen.path, name: MyPlansScreen.name, builder: (context, state) => const MyPlansScreen()),
|
||||
GoRoute(path: MyRecipesScreen.path, name: MyRecipesScreen.name, builder: (context, state) => const MyRecipesScreen()),
|
||||
|
||||
11
lib/core/utils/url_utils.dart
Normal file
11
lib/core/utils/url_utils.dart
Normal file
@@ -0,0 +1,11 @@
|
||||
/// Joins an API media path with [baseUrl].
|
||||
///
|
||||
/// Uploaded photos come back as relative paths (`/storage/...`).
|
||||
/// Default avatars are already absolute (`https://ui-avatars...`) and must stay as-is.
|
||||
String joinBaseUrl(String baseUrl, String path) {
|
||||
final normalized = path.toLowerCase();
|
||||
if (normalized.startsWith('https://') || normalized.startsWith('http://')) {
|
||||
return path;
|
||||
}
|
||||
return '$baseUrl$path';
|
||||
}
|
||||
@@ -6,12 +6,20 @@ import 'package:eatvio_app/core/utils/toasts.dart';
|
||||
import 'package:eatvio_app/core/widgets/show_error_dialog.dart';
|
||||
import 'package:eatvio_app/features/blog_post_create/bloc/blog_post_create_cubit/blog_post_create_cubit.dart';
|
||||
import 'package:eatvio_app/features/complaining/presentation/complaining_bottom_sheet.dart';
|
||||
import 'package:eatvio_app/features/create_diet/presentation/create_diet_screen.dart';
|
||||
import 'package:eatvio_app/features/create_plan/presentation/create_plan_screen.dart';
|
||||
import 'package:eatvio_app/features/create_recipe/presentation/pages/recipe_create_screen.dart';
|
||||
import 'package:eatvio_app/features/diets/presentation/cubit/about_diet_cubit.dart';
|
||||
import 'package:eatvio_app/features/feed/presentation/bloc/diets_cubit/diets_cubit.dart';
|
||||
import 'package:eatvio_app/features/feed/presentation/bloc/feed_cubit/feed_cubit.dart';
|
||||
import 'package:eatvio_app/features/feed/presentation/bloc/plans_cubit/plans_cubit.dart';
|
||||
import 'package:eatvio_app/features/feed/presentation/bloc/recipes_cubit/recipes_cubit.dart';
|
||||
import 'package:eatvio_app/features/feed/models/diets_pagination_model.dart';
|
||||
import 'package:eatvio_app/features/feed/models/feed_blog_post_model.dart';
|
||||
import 'package:eatvio_app/features/feed/models/plan_pagination_model.dart';
|
||||
import 'package:eatvio_app/features/feed/models/recipes_model.dart';
|
||||
import 'package:eatvio_app/features/feed/presentation/bloc/diets_cubit/diets_cubit.dart';
|
||||
import 'package:eatvio_app/features/feed/presentation/bloc/recipes_cubit/recipes_cubit.dart';
|
||||
import 'package:eatvio_app/features/plans/presentation/cubit/about_plan_cubit.dart';
|
||||
import 'package:eatvio_app/features/recipe/presentation/cubit/about_recipe_cubit.dart';
|
||||
import 'package:eatvio_app/features/menu/presentation/blocked_user_cubit/blocked_users_cubit.dart';
|
||||
import 'package:eatvio_app/features/my_plans/presentation/cubit/my_plans_cubit.dart';
|
||||
import 'package:eatvio_app/features/profile/presentation/bloc/profile_cubit/profile_cubit.dart';
|
||||
@@ -33,6 +41,7 @@ class CustomPopup extends StatelessWidget {
|
||||
final FeedBlogPostModel? feedBlogPostModel;
|
||||
final Recipe? recipe;
|
||||
final Diet? diet;
|
||||
final PlanModel? plan;
|
||||
final String userSlug;
|
||||
final VoidCallback? onPostsChanged;
|
||||
|
||||
@@ -47,6 +56,7 @@ class CustomPopup extends StatelessWidget {
|
||||
this.feedBlogPostModel,
|
||||
this.recipe,
|
||||
this.diet,
|
||||
this.plan,
|
||||
required this.userSlug,
|
||||
this.onPostsChanged,
|
||||
});
|
||||
@@ -86,7 +96,103 @@ class CustomPopup extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
List<PopupMenuEntry> _buildMenuItems(S s,BuildContext context) {
|
||||
Future<void> _openEditor(BuildContext context) async {
|
||||
final router = GoRouter.of(context);
|
||||
switch (complainedContentType) {
|
||||
case ComplainedContent.posts:
|
||||
await router.pushNamed('blogPostCreateScreen', extra: feedBlogPostModel);
|
||||
onPostsChanged?.call();
|
||||
case ComplainedContent.recipe:
|
||||
final currentRecipe = recipe;
|
||||
if (currentRecipe == null) {
|
||||
return;
|
||||
}
|
||||
final result = await router.pushNamed(RecipeCreateScreen.name, extra: currentRecipe);
|
||||
if (result != null && context.mounted) {
|
||||
_reloadEditedContent(context);
|
||||
}
|
||||
case ComplainedContent.diet:
|
||||
final currentDiet = diet;
|
||||
if (currentDiet == null) {
|
||||
return;
|
||||
}
|
||||
final result = await router.pushNamed(CreateDietScreen.name, extra: {'diet': currentDiet, 'isEditing': true});
|
||||
if (result == true && context.mounted) {
|
||||
_reloadEditedContent(context);
|
||||
}
|
||||
case ComplainedContent.plan:
|
||||
final currentPlan = plan;
|
||||
if (currentPlan == null) {
|
||||
return;
|
||||
}
|
||||
final result = await router.pushNamed(CreatePlanScreen.name, extra: currentPlan);
|
||||
if (result == true && context.mounted) {
|
||||
_reloadEditedContent(context);
|
||||
}
|
||||
default:
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
void _reloadEditedContent(BuildContext context) {
|
||||
switch (complainedContentType) {
|
||||
case ComplainedContent.recipe:
|
||||
final currentRecipe = recipe;
|
||||
if (currentRecipe == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
context.read<AboutRecipeCubit>().getRecipe(currentRecipe.slug);
|
||||
} catch (_) {}
|
||||
try {
|
||||
context.read<RecipesCubit>().loadRecipesList();
|
||||
} catch (_) {}
|
||||
case ComplainedContent.diet:
|
||||
final currentDiet = diet;
|
||||
if (currentDiet == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
context.read<AboutDietCubit>().getDiet(id: currentDiet.id, slug: currentDiet.slug);
|
||||
} catch (_) {}
|
||||
try {
|
||||
context.read<DietsCubit>().loadDiets();
|
||||
} catch (_) {}
|
||||
case ComplainedContent.plan:
|
||||
final currentPlan = plan;
|
||||
if (currentPlan == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
context.read<AboutPlanCubit>().getPlan(slug: currentPlan.slug);
|
||||
} catch (_) {}
|
||||
try {
|
||||
context.read<PlansCubit>().loadPlans();
|
||||
} catch (_) {}
|
||||
try {
|
||||
context.read<MyPlansCubit>().loadPlans();
|
||||
} catch (_) {}
|
||||
default:
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
bool _showsEditAction() {
|
||||
switch (complainedContentType) {
|
||||
case ComplainedContent.posts:
|
||||
return feedBlogPostModel != null || id != null;
|
||||
case ComplainedContent.recipe:
|
||||
return recipe != null;
|
||||
case ComplainedContent.diet:
|
||||
return diet != null;
|
||||
case ComplainedContent.plan:
|
||||
return plan != null;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
List<PopupMenuEntry> _buildMenuItems(S s, BuildContext context) {
|
||||
final entries = <PopupMenuEntry>[];
|
||||
|
||||
void addItem(String icon, String text, Function() onTap) {
|
||||
@@ -110,12 +216,9 @@ class CustomPopup extends StatelessWidget {
|
||||
),
|
||||
);
|
||||
});
|
||||
if (complainedContentType == ComplainedContent.posts) {
|
||||
addItem('assets/icons/edit_paint.svg', 'Редактировать', () async {
|
||||
if (complainedContentType == ComplainedContent.posts) {
|
||||
await GoRouter.of(context).pushNamed('blogPostCreateScreen', extra: feedBlogPostModel);
|
||||
onPostsChanged?.call();
|
||||
}
|
||||
if (_showsEditAction()) {
|
||||
addItem('assets/icons/edit_paint.svg', s.edit, () {
|
||||
_openEditor(context);
|
||||
});
|
||||
}
|
||||
addItem('assets/icons/red_trash.svg', 'Удалить', () async {
|
||||
|
||||
@@ -20,7 +20,7 @@ class SuccessPublishedDietOrPlanBottomSheet extends StatelessWidget {
|
||||
if (isDrafted == true) {
|
||||
text = 'cохранена в черновиках';
|
||||
} else if (isUpdated == true) {
|
||||
text = 'успешно обновлена';
|
||||
text = isPlan ? 'успешно обновлён' : 'успешно обновлена';
|
||||
} else if (isPlan == true) {
|
||||
text = 'опубликован';
|
||||
} else {
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'package:dismissible_page/dismissible_page.dart';
|
||||
import 'package:eatvio_app/core/app_config.dart';
|
||||
import 'package:eatvio_app/core/enums/complained_content.dart';
|
||||
import 'package:eatvio_app/core/utils/date_time_utils.dart';
|
||||
import 'package:eatvio_app/core/utils/url_utils.dart';
|
||||
import 'package:eatvio_app/core/widgets/custom_popup.dart';
|
||||
import 'package:eatvio_app/core/widgets/loader.dart';
|
||||
import 'package:eatvio_app/core/widgets/markdown_html_widget.dart';
|
||||
@@ -26,7 +27,11 @@ class BlogPostScreen extends StatefulWidget {
|
||||
final int id;
|
||||
final int? highlightedCommentId;
|
||||
|
||||
const BlogPostScreen({super.key, required this.id, this.highlightedCommentId});
|
||||
const BlogPostScreen({
|
||||
super.key,
|
||||
required this.id,
|
||||
this.highlightedCommentId,
|
||||
});
|
||||
|
||||
@override
|
||||
State<BlogPostScreen> createState() => _BlogPostScreenState();
|
||||
@@ -45,7 +50,10 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
|
||||
}
|
||||
|
||||
void _openGallery(List<String> imageUrls, int index, {Object? heroTag}) {
|
||||
context.pushTransparentRoute(rootNavigator: true, PhotoViewScreen(imageUrls: imageUrls, index: index, heroTag: heroTag));
|
||||
context.pushTransparentRoute(
|
||||
rootNavigator: true,
|
||||
PhotoViewScreen(imageUrls: imageUrls, index: index, heroTag: heroTag),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -53,7 +61,8 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
|
||||
return BlocProvider(
|
||||
create: (context) => sl<BlogPostCubit>()..getBlogPost(widget.id),
|
||||
child: BlocProvider<CommentsCubit>(
|
||||
create: (BuildContext context) => commentsCubit..getComments(widget.id.toString(), CommentSourceType.blogPost, true),
|
||||
create: (BuildContext context) => commentsCubit
|
||||
..getComments(widget.id.toString(), CommentSourceType.blogPost, true),
|
||||
child: BlocBuilder<BlogPostCubit, BlogPostState>(
|
||||
builder: (context, blogPostState) {
|
||||
return BlocConsumer<CommentsCubit, CommentsState>(
|
||||
@@ -81,15 +90,23 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
|
||||
body: const Loader(),
|
||||
);
|
||||
}
|
||||
final bool isLiked = blogPostState.blogPostModel?.wasLikedByMe ?? false;
|
||||
final int totalLikes = blogPostState.blogPostModel?.totalLikes ?? 0;
|
||||
final bool isLiked =
|
||||
blogPostState.blogPostModel?.wasLikedByMe ?? false;
|
||||
final int totalLikes =
|
||||
blogPostState.blogPostModel?.totalLikes ?? 0;
|
||||
return Scaffold(
|
||||
resizeToAvoidBottomInset: true,
|
||||
bottomSheet: Container(
|
||||
padding: EdgeInsets.only(bottom: MediaQuery.of(context).padding.bottom + 8),
|
||||
padding: EdgeInsets.only(
|
||||
bottom: MediaQuery.of(context).padding.bottom + 8,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
border: Border(top: BorderSide(color: Colors.black.withValues(alpha: 0.1))),
|
||||
border: Border(
|
||||
top: BorderSide(
|
||||
color: Colors.black.withValues(alpha: 0.1),
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
|
||||
@@ -107,10 +124,17 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
|
||||
FocusManager.instance.primaryFocus?.unfocus();
|
||||
},
|
||||
child: Container(
|
||||
decoration: BoxDecoration(color: appTheme.blueA400, shape: BoxShape.circle),
|
||||
decoration: BoxDecoration(
|
||||
color: appTheme.blueA400,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
height: 32,
|
||||
width: 32,
|
||||
child: const Icon(Icons.close_rounded, size: 18, color: Colors.white),
|
||||
child: const Icon(
|
||||
Icons.close_rounded,
|
||||
size: 18,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (replyId != null) const SizedBox(width: 8),
|
||||
@@ -118,7 +142,8 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(minHeight: 52),
|
||||
child: TextFormField(
|
||||
textCapitalization: TextCapitalization.sentences,
|
||||
textCapitalization:
|
||||
TextCapitalization.sentences,
|
||||
onChanged: (_) {
|
||||
setState(() {});
|
||||
},
|
||||
@@ -126,26 +151,48 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
|
||||
maxLines: 5,
|
||||
minLines: 1,
|
||||
keyboardType: TextInputType.multiline,
|
||||
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w400, color: Colors.black, height: 1.2),
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w400,
|
||||
color: Colors.black,
|
||||
height: 1.2,
|
||||
),
|
||||
decoration: InputDecoration(
|
||||
isDense: false,
|
||||
// убираем true, чтобы padding учитывался корректно
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 16,
|
||||
),
|
||||
// вертикальный паддинг увеличен
|
||||
hintText: S.of(context).comment_hint,
|
||||
hintStyle: const TextStyle(fontSize: 16, fontWeight: FontWeight.w400, height: 1.2, color: Color(0xFFAEAEB2)),
|
||||
hintStyle: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w400,
|
||||
height: 1.2,
|
||||
color: Color(0xFFAEAEB2),
|
||||
),
|
||||
filled: true,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
borderSide: const BorderSide(color: Color(0xFFD1D1D6), width: 1),
|
||||
borderSide: const BorderSide(
|
||||
color: Color(0xFFD1D1D6),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
borderSide: const BorderSide(color: Color(0xFFD1D1D6), width: 1),
|
||||
borderSide: const BorderSide(
|
||||
color: Color(0xFFD1D1D6),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
borderSide: const BorderSide(color: Color(0xFFD1D1D6), width: 1),
|
||||
borderSide: const BorderSide(
|
||||
color: Color(0xFFD1D1D6),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -163,7 +210,11 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
|
||||
);
|
||||
if (!context.mounted) return;
|
||||
|
||||
if (isSent) context.read<FeedCubit>().incrementCommentsState(widget.id);
|
||||
if (isSent) {
|
||||
context
|
||||
.read<FeedCubit>()
|
||||
.incrementCommentsState(widget.id);
|
||||
}
|
||||
|
||||
context.read<CommentsCubit>().setReplyId(null);
|
||||
setState(() {
|
||||
@@ -174,10 +225,19 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
|
||||
}
|
||||
},
|
||||
child: Container(
|
||||
decoration: BoxDecoration(color: textEditingController.text.length > 2 ? appTheme.blueA400 : Colors.grey, shape: BoxShape.circle),
|
||||
decoration: BoxDecoration(
|
||||
color: textEditingController.text.length > 2
|
||||
? appTheme.blueA400
|
||||
: Colors.grey,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
height: 32,
|
||||
width: 32,
|
||||
child: const Icon(Icons.send_rounded, size: 18, color: Colors.white),
|
||||
child: const Icon(
|
||||
Icons.send_rounded,
|
||||
size: 18,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -197,24 +257,36 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
|
||||
),
|
||||
actions: [
|
||||
CustomPopup(
|
||||
isAuthor: blogPostState.blogPostModel?.author.authorIsMe ?? false,
|
||||
isAuthorAdmin: blogPostState.blogPostModel?.author.isAdmin ?? false,
|
||||
isAuthor:
|
||||
blogPostState.blogPostModel?.author.authorIsMe ??
|
||||
false,
|
||||
isAuthorAdmin:
|
||||
blogPostState.blogPostModel?.author.isAdmin ??
|
||||
false,
|
||||
complainedContentType: ComplainedContent.posts,
|
||||
path: 'blog_post/${(widget.id)}',
|
||||
id: widget.id,
|
||||
feedBlogPostModel: blogPostState.blogPostModel,
|
||||
userSlug: blogPostState.blogPostModel?.author.authorSlug ?? '',
|
||||
userSlug:
|
||||
blogPostState.blogPostModel?.author.authorSlug ??
|
||||
'',
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
],
|
||||
),
|
||||
body: SingleChildScrollView(
|
||||
padding: EdgeInsets.only(bottom: 160 + MediaQuery.of(context).padding.bottom), // поле комментария (52-200) + padding (12+12+56) + safe area
|
||||
padding: EdgeInsets.only(
|
||||
bottom: 160 + MediaQuery.of(context).padding.bottom,
|
||||
), // поле комментария (52-200) + padding (12+12+56) + safe area
|
||||
physics: const BouncingScrollPhysics(),
|
||||
child: Column(
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: () => GoRouter.of(context).pushNamed('profileScreen', extra: blogPostState.blogPostModel?.author.authorSlug),
|
||||
onTap: () => GoRouter.of(context).pushNamed(
|
||||
'profileScreen',
|
||||
extra:
|
||||
blogPostState.blogPostModel?.author.authorSlug,
|
||||
),
|
||||
child: Ink(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Row(
|
||||
@@ -223,21 +295,48 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
|
||||
children: [
|
||||
CircleAvatar(
|
||||
radius: 16,
|
||||
foregroundImage: NetworkImage(sl<AppConfig>().baseUrl + (blogPostState.blogPostModel?.author.authorProfileIcon ?? '')),
|
||||
backgroundImage: const AssetImage('assets/images/no_avatar.png'),
|
||||
foregroundImage: NetworkImage(
|
||||
joinBaseUrl(
|
||||
sl<AppConfig>().baseUrl,
|
||||
blogPostState
|
||||
.blogPostModel
|
||||
?.author
|
||||
.authorProfileIcon ??
|
||||
'',
|
||||
),
|
||||
),
|
||||
backgroundImage: const AssetImage(
|
||||
'assets/images/no_avatar.png',
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
blogPostState.blogPostModel?.author.authorName ?? '',
|
||||
style: const TextStyle(color: Colors.black, fontSize: 14, fontWeight: FontWeight.w400, height: 1),
|
||||
blogPostState
|
||||
.blogPostModel
|
||||
?.author
|
||||
.authorName ??
|
||||
'',
|
||||
style: const TextStyle(
|
||||
color: Colors.black,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w400,
|
||||
height: 1,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
(blogPostState.blogPostModel?.createdAt ?? DateTime.now()).format('dd MMMM', 'ru'),
|
||||
style: TextStyle(color: Colors.grey.shade600, fontSize: 12, fontWeight: FontWeight.w400, height: 1),
|
||||
(blogPostState.blogPostModel?.createdAt ??
|
||||
DateTime.now())
|
||||
.format('dd MMMM', 'ru'),
|
||||
style: TextStyle(
|
||||
color: Colors.grey.shade600,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w400,
|
||||
height: 1,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -245,10 +344,20 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
|
||||
),
|
||||
),
|
||||
),
|
||||
if (blogPostState.blogPostModel?.description.isNotEmpty ?? false)
|
||||
if (blogPostState
|
||||
.blogPostModel
|
||||
?.description
|
||||
.isNotEmpty ??
|
||||
false)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: MarkdownHtmlWidget(markdownText: blogPostState.blogPostModel!.description),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 8,
|
||||
),
|
||||
child: MarkdownHtmlWidget(
|
||||
markdownText:
|
||||
blogPostState.blogPostModel!.description,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Builder(
|
||||
@@ -257,16 +366,31 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
|
||||
if (post == null) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
final imageUrl = PostFeedMapper.resolveMediaUrl(post.image);
|
||||
final imageUrl = PostFeedMapper.resolveMediaUrl(
|
||||
post.image,
|
||||
);
|
||||
final embedUrl = post.videoEmbedUrl?.trim();
|
||||
return BlogPostMediaContent(
|
||||
galleryImages: post.images,
|
||||
imageUrl: imageUrl,
|
||||
videoEmbedUrl: embedUrl != null && embedUrl.isNotEmpty ? embedUrl : null,
|
||||
videoUrlThumb: post.videoUrlThumb != null ? PostFeedMapper.resolveMediaUrl(post.videoUrlThumb) : null,
|
||||
videoEmbedUrl:
|
||||
embedUrl != null && embedUrl.isNotEmpty
|
||||
? embedUrl
|
||||
: null,
|
||||
videoUrlThumb: post.videoUrlThumb != null
|
||||
? PostFeedMapper.resolveMediaUrl(
|
||||
post.videoUrlThumb,
|
||||
)
|
||||
: null,
|
||||
currentGalleryIndex: currentGalleryIndex,
|
||||
heroTag: 'blog-post-screen-${post.id}-cover',
|
||||
onOpenGallery: (urls, index) => _openGallery(urls, index, heroTag: urls.length == 1 ? 'blog-post-screen-${post.id}-cover' : null),
|
||||
onOpenGallery: (urls, index) => _openGallery(
|
||||
urls,
|
||||
index,
|
||||
heroTag: urls.length == 1
|
||||
? 'blog-post-screen-${post.id}-cover'
|
||||
: null,
|
||||
),
|
||||
onGalleryChanged: (index) {
|
||||
setState(() {
|
||||
currentGalleryIndex = index;
|
||||
@@ -276,22 +400,43 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
|
||||
},
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 12,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
_iconButton(
|
||||
icon: isLiked ? Icons.favorite_rounded : Icons.favorite_border_rounded,
|
||||
icon: isLiked
|
||||
? Icons.favorite_rounded
|
||||
: Icons.favorite_border_rounded,
|
||||
text: totalLikes.toString(),
|
||||
iconColor: isLiked ? Colors.red.withValues(alpha: 0.8) : null,
|
||||
backgroundColor: isLiked ? Colors.red.withValues(alpha: 0.1) : null,
|
||||
textColor: isLiked ? Colors.red.withValues(alpha: 0.8) : null,
|
||||
iconColor: isLiked
|
||||
? Colors.red.withValues(alpha: 0.8)
|
||||
: null,
|
||||
backgroundColor: isLiked
|
||||
? Colors.red.withValues(alpha: 0.1)
|
||||
: null,
|
||||
textColor: isLiked
|
||||
? Colors.red.withValues(alpha: 0.8)
|
||||
: null,
|
||||
onTap: () async {
|
||||
await context.read<BlogPostCubit>().toggleLike();
|
||||
await context
|
||||
.read<BlogPostCubit>()
|
||||
.toggleLike();
|
||||
// ignore: use_build_context_synchronously
|
||||
final updated = context.read<BlogPostCubit>().state.blogPostModel;
|
||||
final updated = context
|
||||
.read<BlogPostCubit>()
|
||||
.state
|
||||
.blogPostModel;
|
||||
if (updated != null) {
|
||||
// ignore: use_build_context_synchronously
|
||||
context.read<FeedCubit>().updateFeedBlogPostList(updated, updated.wasLikedByMe);
|
||||
context
|
||||
.read<FeedCubit>()
|
||||
.updateFeedBlogPostList(
|
||||
updated,
|
||||
updated.wasLikedByMe,
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
@@ -299,21 +444,36 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
|
||||
_iconButton(
|
||||
icon: Icons.reply_rounded,
|
||||
onTap: () {
|
||||
final box = context.findRenderObject() as RenderBox?;
|
||||
final box =
|
||||
context.findRenderObject() as RenderBox?;
|
||||
SharePlus.instance.share(
|
||||
ShareParams(
|
||||
uri: Uri.parse('${sl<AppConfig>().baseUrl}/ru/blog_post/${widget.id}'),
|
||||
sharePositionOrigin: box!.localToGlobal(Offset.zero) & box.size,
|
||||
uri: Uri.parse(
|
||||
'${sl<AppConfig>().baseUrl}/ru/blog_post/${widget.id}',
|
||||
),
|
||||
sharePositionOrigin:
|
||||
box!.localToGlobal(Offset.zero) &
|
||||
box.size,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
const Spacer(),
|
||||
Icon(Icons.visibility_rounded, size: 16, color: appTheme.gray500),
|
||||
Icon(
|
||||
Icons.visibility_rounded,
|
||||
size: 16,
|
||||
color: appTheme.gray500,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
blogPostState.blogPostModel?.totalViews.toString() ?? '',
|
||||
style: TextStyle(fontSize: 12, color: appTheme.gray500, fontWeight: FontWeight.w400),
|
||||
blogPostState.blogPostModel?.totalViews
|
||||
.toString() ??
|
||||
'',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: appTheme.gray500,
|
||||
fontWeight: FontWeight.w400,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -321,7 +481,10 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
|
||||
Container(
|
||||
width: MediaQuery.of(context).size.width - 32,
|
||||
height: 0.4,
|
||||
decoration: BoxDecoration(color: Colors.black.withValues(alpha: 0.2), borderRadius: BorderRadius.circular(16)),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withValues(alpha: 0.2),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
@@ -329,7 +492,11 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Text(
|
||||
'${blogPostState.blogPostModel?.totalComments} КОММЕНТАРИЕВ',
|
||||
style: TextStyle(fontSize: 12, color: appTheme.gray500, fontWeight: FontWeight.w400),
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: appTheme.gray500,
|
||||
fontWeight: FontWeight.w400,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
@@ -354,13 +521,23 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
InkWell _iconButton({required IconData icon, String? text, Function()? onTap, Color? iconColor, Color? backgroundColor, Color? textColor}) {
|
||||
InkWell _iconButton({
|
||||
required IconData icon,
|
||||
String? text,
|
||||
Function()? onTap,
|
||||
Color? iconColor,
|
||||
Color? backgroundColor,
|
||||
Color? textColor,
|
||||
}) {
|
||||
return InkWell(
|
||||
onTap: onTap ?? () {},
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: Ink(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||
decoration: BoxDecoration(color: backgroundColor ?? appTheme.gray100, borderRadius: BorderRadius.circular(16)),
|
||||
decoration: BoxDecoration(
|
||||
color: backgroundColor ?? appTheme.gray100,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
height: 32,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
@@ -370,7 +547,10 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
|
||||
if (text != null)
|
||||
Text(
|
||||
text,
|
||||
style: TextStyle(color: textColor ?? appTheme.gray600, fontWeight: FontWeight.w500),
|
||||
style: TextStyle(
|
||||
color: textColor ?? appTheme.gray600,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -674,163 +674,185 @@ class _BlogPostCreateScreenState extends State<BlogPostCreateScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
Scaffold _buildBlogPostCreate(BuildContext context) {
|
||||
return Scaffold(
|
||||
resizeToAvoidBottomInset: true,
|
||||
floatingActionButtonLocation: FloatingActionButtonLocation.centerFloat,
|
||||
floatingActionButton: enableMdPanel || isPreviewMode
|
||||
? Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
if (!isPreviewMode) ...[
|
||||
Container(
|
||||
width: MediaQuery.of(context).size.width - 76,
|
||||
height: 52,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: const Color(0x00000000).withValues(alpha: 0.2),
|
||||
spreadRadius: 0,
|
||||
blurRadius: 32,
|
||||
offset: const Offset(0, 0),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const SizedBox(width: 8),
|
||||
_mdTextButton('H', _applyHeading),
|
||||
const SizedBox(width: 8),
|
||||
_mdButton(Icons.format_bold, _applyBold),
|
||||
const SizedBox(width: 8),
|
||||
_mdButton(Icons.format_italic, _applyItalic),
|
||||
const SizedBox(width: 8),
|
||||
_mdButton(
|
||||
Icons.format_list_numbered,
|
||||
_applyNumberedList,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_mdButton(
|
||||
Icons.format_list_bulleted,
|
||||
_applyBulletedList,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_mdButton(Icons.link, _applyLink),
|
||||
const SizedBox(width: 8),
|
||||
_mdButton(Icons.horizontal_rule, _applyHorizontalRule),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
Container(
|
||||
width: 52,
|
||||
height: 52,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: const Color(0x00000000).withValues(alpha: 0.2),
|
||||
spreadRadius: 0,
|
||||
blurRadius: 32,
|
||||
offset: const Offset(0, 0),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
_mdButton(
|
||||
isPreviewMode ? Icons.edit : Icons.visibility,
|
||||
_togglePreview,
|
||||
),
|
||||
],
|
||||
),
|
||||
Widget _buildEditorToolbar(BuildContext context) {
|
||||
return Row(
|
||||
key: const ValueKey('blogPostCreateEditorToolbar'),
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
if (!isPreviewMode) ...[
|
||||
Container(
|
||||
width: MediaQuery.of(context).size.width - 76,
|
||||
height: 52,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: const Color(0x00000000).withValues(alpha: 0.2),
|
||||
spreadRadius: 0,
|
||||
blurRadius: 32,
|
||||
offset: const Offset(0, 0),
|
||||
),
|
||||
],
|
||||
)
|
||||
: null,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const SizedBox(width: 8),
|
||||
_mdTextButton('H', _applyHeading),
|
||||
const SizedBox(width: 8),
|
||||
_mdButton(Icons.format_bold, _applyBold),
|
||||
const SizedBox(width: 8),
|
||||
_mdButton(Icons.format_italic, _applyItalic),
|
||||
const SizedBox(width: 8),
|
||||
_mdButton(Icons.format_list_numbered, _applyNumberedList),
|
||||
const SizedBox(width: 8),
|
||||
_mdButton(Icons.format_list_bulleted, _applyBulletedList),
|
||||
const SizedBox(width: 8),
|
||||
_mdButton(Icons.link, _applyLink),
|
||||
const SizedBox(width: 8),
|
||||
_mdButton(Icons.horizontal_rule, _applyHorizontalRule),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
Container(
|
||||
width: 52,
|
||||
height: 52,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: const Color(0x00000000).withValues(alpha: 0.2),
|
||||
spreadRadius: 0,
|
||||
blurRadius: 32,
|
||||
offset: const Offset(0, 0),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
_mdButton(
|
||||
isPreviewMode ? Icons.edit : Icons.visibility,
|
||||
_togglePreview,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Scaffold _buildBlogPostCreate(BuildContext context) {
|
||||
final editorToolbarVisible = enableMdPanel || isPreviewMode;
|
||||
|
||||
return Scaffold(
|
||||
resizeToAvoidBottomInset: true,
|
||||
appBar: AppBar(),
|
||||
body: BlocBuilder<BlogPostCreateCubit, BlogPostCreateState>(
|
||||
bloc: blogPostCubit,
|
||||
builder: (context, state) {
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text('Заголовок'),
|
||||
const SizedBox(height: 8),
|
||||
CustomTextFormField(
|
||||
key: AppKeys.blogPostCreateScreenTitleTextField,
|
||||
controller: titleController,
|
||||
hasError: state.titleError != null,
|
||||
onChanged: (_) => blogPostCubit.clearTitleError(),
|
||||
),
|
||||
if (state.titleError != null) ...[
|
||||
const SizedBox(height: 4),
|
||||
ValidationErrorWidget(error: state.titleError!),
|
||||
],
|
||||
const SizedBox(height: 16),
|
||||
const Text('Описание'),
|
||||
const SizedBox(height: 8),
|
||||
isPreviewMode
|
||||
? _buildMarkdownPreview()
|
||||
: CustomTextFormField(
|
||||
key: AppKeys.blogPostCreateScreenContentTextField,
|
||||
controller: descriptionController,
|
||||
focusNode: descriptionFocusNode,
|
||||
maxLines: 6,
|
||||
minLines: 4,
|
||||
hasError: state.descriptionError != null,
|
||||
onChanged: (_) => blogPostCubit.clearDescriptionError(),
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text('Заголовок'),
|
||||
const SizedBox(height: 8),
|
||||
CustomTextFormField(
|
||||
key: AppKeys.blogPostCreateScreenTitleTextField,
|
||||
controller: titleController,
|
||||
hasError: state.titleError != null,
|
||||
onChanged: (_) => blogPostCubit.clearTitleError(),
|
||||
),
|
||||
if (state.descriptionError != null) ...[
|
||||
const SizedBox(height: 4),
|
||||
ValidationErrorWidget(error: state.descriptionError!),
|
||||
],
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Изображения ($_galleryCount/${BlogPostRepository.maxGalleryImages})',
|
||||
if (state.titleError != null) ...[
|
||||
const SizedBox(height: 4),
|
||||
ValidationErrorWidget(error: state.titleError!),
|
||||
],
|
||||
const SizedBox(height: 16),
|
||||
const Text('Описание'),
|
||||
const SizedBox(height: 8),
|
||||
isPreviewMode
|
||||
? _buildMarkdownPreview()
|
||||
: CustomTextFormField(
|
||||
key: AppKeys.blogPostCreateScreenContentTextField,
|
||||
controller: descriptionController,
|
||||
focusNode: descriptionFocusNode,
|
||||
maxLines: 6,
|
||||
minLines: 4,
|
||||
hasError: state.descriptionError != null,
|
||||
onChanged: (_) =>
|
||||
blogPostCubit.clearDescriptionError(),
|
||||
),
|
||||
if (state.descriptionError != null) ...[
|
||||
const SizedBox(height: 4),
|
||||
ValidationErrorWidget(error: state.descriptionError!),
|
||||
],
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Изображения ($_galleryCount/${BlogPostRepository.maxGalleryImages})',
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_buildGallery(),
|
||||
if (state.imagesError != null) ...[
|
||||
const SizedBox(height: 4),
|
||||
ValidationErrorWidget(error: state.imagesError!),
|
||||
],
|
||||
const SizedBox(height: 16),
|
||||
const Text('Ссылка на видео'),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Rutube, VK или OK',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: appTheme.blueGray300,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
CustomTextFormField(
|
||||
key: AppKeys.blogPostCreateScreenVideoLinkTextField,
|
||||
controller: videoUrlController,
|
||||
hasError: state.videoUrlError != null,
|
||||
keyboardType: TextInputType.url,
|
||||
onChanged: (_) => blogPostCubit.clearVideoUrlError(),
|
||||
),
|
||||
if (state.videoUrlError != null) ...[
|
||||
const SizedBox(height: 4),
|
||||
ValidationErrorWidget(error: state.videoUrlError!),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (editorToolbarVisible) ...[
|
||||
const SizedBox(height: 8),
|
||||
_buildGallery(),
|
||||
if (state.imagesError != null) ...[
|
||||
const SizedBox(height: 4),
|
||||
ValidationErrorWidget(error: state.imagesError!),
|
||||
],
|
||||
const SizedBox(height: 16),
|
||||
const Text('Ссылка на видео'),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Rutube, VK или OK',
|
||||
style: TextStyle(fontSize: 13, color: appTheme.blueGray300),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
CustomTextFormField(
|
||||
key: AppKeys.blogPostCreateScreenVideoLinkTextField,
|
||||
controller: videoUrlController,
|
||||
hasError: state.videoUrlError != null,
|
||||
keyboardType: TextInputType.url,
|
||||
onChanged: (_) => blogPostCubit.clearVideoUrlError(),
|
||||
),
|
||||
if (state.videoUrlError != null) ...[
|
||||
const SizedBox(height: 4),
|
||||
ValidationErrorWidget(error: state.videoUrlError!),
|
||||
],
|
||||
if (state.formError != null) ...[
|
||||
const SizedBox(height: 16),
|
||||
ValidationErrorWidget(error: state.formError!),
|
||||
],
|
||||
const SizedBox(height: 80),
|
||||
_createBlogPostButton(isSubmitting: state.isLoading),
|
||||
const SizedBox(height: 80),
|
||||
_buildEditorToolbar(context),
|
||||
],
|
||||
),
|
||||
SafeArea(
|
||||
top: false,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (state.formError != null) ...[
|
||||
ValidationErrorWidget(error: state.formError!),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
_createBlogPostButton(isSubmitting: state.isLoading),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
@@ -897,9 +919,8 @@ class _BlogPostCreateScreenState extends State<BlogPostCreateScreen> {
|
||||
onTap: isSubmitting ? null : _submitBlogPost,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: isSubmitting
|
||||
? const Center(child: Loader())
|
||||
? const SizedBox(height: 52, child: Center(child: Loader()))
|
||||
: Ink(
|
||||
width: MediaQuery.of(context).size.width - 32,
|
||||
height: 52,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'package:eatvio_app/core/app_config.dart';
|
||||
import 'package:eatvio_app/core/env_config.dart';
|
||||
import 'package:eatvio_app/core/locator_service.dart';
|
||||
import 'package:eatvio_app/core/utils/image_constant.dart';
|
||||
import 'package:eatvio_app/core/utils/url_utils.dart';
|
||||
import 'package:eatvio_app/features/dev_tools/presentation/dev_tools_screen.dart';
|
||||
import 'package:eatvio_app/features/notifications/presentation/notifications_cubit/notifications_cubit.dart';
|
||||
import 'package:eatvio_app/features/notifications/presentation/notifications_cubit/notifications_state.dart';
|
||||
@@ -26,11 +27,13 @@ class BottomNavigationBarWidget extends StatefulWidget {
|
||||
});
|
||||
|
||||
final Widget child;
|
||||
|
||||
/// Текущий путь из GoRouter (matchedLocation), чтобы подсветка таба совпадала с экраном при навигации через go() (диплинки, пуши).
|
||||
final String currentLocation;
|
||||
|
||||
@override
|
||||
State<BottomNavigationBarWidget> createState() => _BottomNavigationBarWidgetState();
|
||||
State<BottomNavigationBarWidget> createState() =>
|
||||
_BottomNavigationBarWidgetState();
|
||||
}
|
||||
|
||||
class _BottomNavigationBarWidgetState extends State<BottomNavigationBarWidget> {
|
||||
@@ -40,9 +43,24 @@ class _BottomNavigationBarWidgetState extends State<BottomNavigationBarWidget> {
|
||||
|
||||
List<String> _getPages(bool isConsultant) {
|
||||
if (isConsultant) {
|
||||
return ['/my-profile-screen', '/feed-screen', '/diary-screen', '/consultations-screen', '/chat-select-screen', '/notifications-screen', '/menu-screen'];
|
||||
return [
|
||||
'/my-profile-screen',
|
||||
'/feed-screen',
|
||||
'/diary-screen',
|
||||
'/consultations-screen',
|
||||
'/chat-select-screen',
|
||||
'/notifications-screen',
|
||||
'/menu-screen',
|
||||
];
|
||||
} else {
|
||||
return ['/my-profile-screen', '/feed-screen', '/diary-screen', '/chat-select-screen', '/notifications-screen', '/menu-screen'];
|
||||
return [
|
||||
'/my-profile-screen',
|
||||
'/feed-screen',
|
||||
'/diary-screen',
|
||||
'/chat-select-screen',
|
||||
'/notifications-screen',
|
||||
'/menu-screen',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,7 +116,9 @@ class _BottomNavigationBarWidgetState extends State<BottomNavigationBarWidget> {
|
||||
final logo = SvgPicture.asset(
|
||||
ImageConstant.imgLogo,
|
||||
height: 32,
|
||||
colorFilter: isProduction ? null : const ColorFilter.mode(_devAccentColor, BlendMode.srcIn),
|
||||
colorFilter: isProduction
|
||||
? null
|
||||
: const ColorFilter.mode(_devAccentColor, BlendMode.srcIn),
|
||||
);
|
||||
if (isProduction) return logo;
|
||||
return Stack(
|
||||
@@ -109,7 +129,10 @@ class _BottomNavigationBarWidgetState extends State<BottomNavigationBarWidget> {
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: _devAccentColor.withValues(alpha: 0.6), width: 1.5),
|
||||
border: Border.all(
|
||||
color: _devAccentColor.withValues(alpha: 0.6),
|
||||
width: 1.5,
|
||||
),
|
||||
color: _devAccentColor.withValues(alpha: 0.08),
|
||||
),
|
||||
child: logo,
|
||||
@@ -122,11 +145,23 @@ class _BottomNavigationBarWidgetState extends State<BottomNavigationBarWidget> {
|
||||
decoration: BoxDecoration(
|
||||
color: _devAccentColor,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
boxShadow: [BoxShadow(color: _devAccentColor.withValues(alpha: 0.4), blurRadius: 4, offset: const Offset(0, 1))],
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: _devAccentColor.withValues(alpha: 0.4),
|
||||
blurRadius: 4,
|
||||
offset: const Offset(0, 1),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: const Text(
|
||||
'DEV',
|
||||
style: TextStyle(fontSize: 8, fontWeight: FontWeight.w700, color: Colors.white, letterSpacing: 0.3, height: 1.0),
|
||||
style: TextStyle(
|
||||
fontSize: 8,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: Colors.white,
|
||||
letterSpacing: 0.3,
|
||||
height: 1.0,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -155,10 +190,15 @@ class _BottomNavigationBarWidgetState extends State<BottomNavigationBarWidget> {
|
||||
// Подсветка таба по текущему маршруту (совпадает при навигации через go(), в т.ч. диплинки/пуши).
|
||||
final pathWithoutQuery = widget.currentLocation.split('?').first;
|
||||
final indexFromRoute = pages!.indexOf(pathWithoutQuery);
|
||||
final effectiveIndex = indexFromRoute >= 0 ? indexFromRoute : currentBottomNavBarIndex;
|
||||
if (indexFromRoute >= 0 && indexFromRoute != currentBottomNavBarIndex) {
|
||||
final effectiveIndex = indexFromRoute >= 0
|
||||
? indexFromRoute
|
||||
: currentBottomNavBarIndex;
|
||||
if (indexFromRoute >= 0 &&
|
||||
indexFromRoute != currentBottomNavBarIndex) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) setState(() => currentBottomNavBarIndex = indexFromRoute);
|
||||
if (mounted) {
|
||||
setState(() => currentBottomNavBarIndex = indexFromRoute);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -176,9 +216,14 @@ class _BottomNavigationBarWidgetState extends State<BottomNavigationBarWidget> {
|
||||
label: '',
|
||||
icon: CircleAvatar(
|
||||
foregroundImage: NetworkImage(
|
||||
profileModel.profilePhotoUrl.contains('https') ? profileModel.profilePhotoUrl : sl<AppConfig>().baseUrl + profileModel.profilePhotoUrl,
|
||||
joinBaseUrl(
|
||||
sl<AppConfig>().baseUrl,
|
||||
profileModel.profilePhotoUrl,
|
||||
),
|
||||
),
|
||||
backgroundImage: const AssetImage(
|
||||
'assets/images/no_avatar.png',
|
||||
),
|
||||
backgroundImage: const AssetImage('assets/images/no_avatar.png'),
|
||||
radius: 16,
|
||||
),
|
||||
),
|
||||
@@ -186,22 +231,54 @@ class _BottomNavigationBarWidgetState extends State<BottomNavigationBarWidget> {
|
||||
label: 'Лента',
|
||||
icon: Padding(
|
||||
padding: const EdgeInsets.only(bottom: 2),
|
||||
child: SvgPicture.asset(colorFilter: const ColorFilter.mode(Colors.grey, BlendMode.srcIn), width: 20, height: 20, ImageConstant.icnFeed),
|
||||
child: SvgPicture.asset(
|
||||
colorFilter: const ColorFilter.mode(
|
||||
Colors.grey,
|
||||
BlendMode.srcIn,
|
||||
),
|
||||
width: 20,
|
||||
height: 20,
|
||||
ImageConstant.icnFeed,
|
||||
),
|
||||
),
|
||||
activeIcon: Padding(
|
||||
padding: const EdgeInsets.only(bottom: 2),
|
||||
child: SvgPicture.asset(ImageConstant.icnFeed, width: 20, height: 20, colorFilter: ColorFilter.mode(appTheme.blueA400, BlendMode.srcIn)),
|
||||
child: SvgPicture.asset(
|
||||
ImageConstant.icnFeed,
|
||||
width: 20,
|
||||
height: 20,
|
||||
colorFilter: ColorFilter.mode(
|
||||
appTheme.blueA400,
|
||||
BlendMode.srcIn,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
BottomNavigationBarItem(
|
||||
label: s.diary,
|
||||
icon: Padding(
|
||||
padding: const EdgeInsets.only(bottom: 2),
|
||||
child: SvgPicture.asset(colorFilter: const ColorFilter.mode(Colors.grey, BlendMode.srcIn), ImageConstant.icnDiary, width: 20, height: 20),
|
||||
child: SvgPicture.asset(
|
||||
colorFilter: const ColorFilter.mode(
|
||||
Colors.grey,
|
||||
BlendMode.srcIn,
|
||||
),
|
||||
ImageConstant.icnDiary,
|
||||
width: 20,
|
||||
height: 20,
|
||||
),
|
||||
),
|
||||
activeIcon: Padding(
|
||||
padding: const EdgeInsets.only(bottom: 2),
|
||||
child: SvgPicture.asset(ImageConstant.icnDiary, width: 20, height: 20, colorFilter: ColorFilter.mode(appTheme.blueA400, BlendMode.srcIn)),
|
||||
child: SvgPicture.asset(
|
||||
ImageConstant.icnDiary,
|
||||
width: 20,
|
||||
height: 20,
|
||||
colorFilter: ColorFilter.mode(
|
||||
appTheme.blueA400,
|
||||
BlendMode.srcIn,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (profileModel.roles.contains('consultant'))
|
||||
@@ -210,7 +287,10 @@ class _BottomNavigationBarWidgetState extends State<BottomNavigationBarWidget> {
|
||||
icon: Padding(
|
||||
padding: const EdgeInsets.only(bottom: 2),
|
||||
child: SvgPicture.asset(
|
||||
colorFilter: const ColorFilter.mode(Colors.grey, BlendMode.srcIn),
|
||||
colorFilter: const ColorFilter.mode(
|
||||
Colors.grey,
|
||||
BlendMode.srcIn,
|
||||
),
|
||||
'assets/icons/consultations.svg',
|
||||
width: 20,
|
||||
height: 20,
|
||||
@@ -222,7 +302,10 @@ class _BottomNavigationBarWidgetState extends State<BottomNavigationBarWidget> {
|
||||
'assets/icons/consultations.svg',
|
||||
width: 20,
|
||||
height: 20,
|
||||
colorFilter: ColorFilter.mode(appTheme.blueA400, BlendMode.srcIn),
|
||||
colorFilter: ColorFilter.mode(
|
||||
appTheme.blueA400,
|
||||
BlendMode.srcIn,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -230,14 +313,26 @@ class _BottomNavigationBarWidgetState extends State<BottomNavigationBarWidget> {
|
||||
label: s.chat,
|
||||
icon: BlocBuilder<ChatsCubit, ChatsState>(
|
||||
builder: (BuildContext context, ChatsState state) {
|
||||
final hasUnreadMessages = state.chatsWithUnreadMessagesCount > 0;
|
||||
final hasUnreadMessages =
|
||||
state.chatsWithUnreadMessagesCount > 0;
|
||||
return badges.Badge(
|
||||
position: badges.BadgePosition.topEnd(),
|
||||
badgeAnimation: const badges.BadgeAnimation.fade(),
|
||||
badgeStyle: badges.BadgeStyle(badgeColor: hasUnreadMessages ? Colors.red : Colors.transparent),
|
||||
badgeStyle: badges.BadgeStyle(
|
||||
badgeColor: hasUnreadMessages
|
||||
? Colors.red
|
||||
: Colors.transparent,
|
||||
),
|
||||
badgeContent: Text(
|
||||
hasUnreadMessages ? state.chatsWithUnreadMessagesCount.toString() : '',
|
||||
style: TextStyle(color: hasUnreadMessages ? Colors.white : Colors.transparent, fontSize: 8),
|
||||
hasUnreadMessages
|
||||
? state.chatsWithUnreadMessagesCount.toString()
|
||||
: '',
|
||||
style: TextStyle(
|
||||
color: hasUnreadMessages
|
||||
? Colors.white
|
||||
: Colors.transparent,
|
||||
fontSize: 8,
|
||||
),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(bottom: 2),
|
||||
@@ -245,9 +340,16 @@ class _BottomNavigationBarWidgetState extends State<BottomNavigationBarWidget> {
|
||||
ImageConstant.icnChat,
|
||||
width: 20,
|
||||
height: 20,
|
||||
colorFilter: pages![effectiveIndex] == '/chat-select-screen'
|
||||
? ColorFilter.mode(appTheme.blueA400, BlendMode.srcIn)
|
||||
: const ColorFilter.mode(Colors.grey, BlendMode.srcIn),
|
||||
colorFilter:
|
||||
pages![effectiveIndex] == '/chat-select-screen'
|
||||
? ColorFilter.mode(
|
||||
appTheme.blueA400,
|
||||
BlendMode.srcIn,
|
||||
)
|
||||
: const ColorFilter.mode(
|
||||
Colors.grey,
|
||||
BlendMode.srcIn,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -261,15 +363,28 @@ class _BottomNavigationBarWidgetState extends State<BottomNavigationBarWidget> {
|
||||
return badges.Badge(
|
||||
position: badges.BadgePosition.topEnd(),
|
||||
badgeAnimation: const badges.BadgeAnimation.fade(),
|
||||
showBadge: state is NotificationsStateLoaded && state.hasUnreadNotifications,
|
||||
badgeContent: const Text('', style: TextStyle(color: Colors.white, fontSize: 8)),
|
||||
showBadge:
|
||||
state is NotificationsStateLoaded &&
|
||||
state.hasUnreadNotifications,
|
||||
badgeContent: const Text(
|
||||
'',
|
||||
style: TextStyle(color: Colors.white, fontSize: 8),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(bottom: 2),
|
||||
child: SvgPicture.asset(
|
||||
ImageConstant.icnBell,
|
||||
colorFilter: pages![effectiveIndex] == '/notifications-screen'
|
||||
? ColorFilter.mode(appTheme.blueA400, BlendMode.srcIn)
|
||||
: const ColorFilter.mode(Colors.grey, BlendMode.srcIn),
|
||||
colorFilter:
|
||||
pages![effectiveIndex] ==
|
||||
'/notifications-screen'
|
||||
? ColorFilter.mode(
|
||||
appTheme.blueA400,
|
||||
BlendMode.srcIn,
|
||||
)
|
||||
: const ColorFilter.mode(
|
||||
Colors.grey,
|
||||
BlendMode.srcIn,
|
||||
),
|
||||
width: 20,
|
||||
height: 20,
|
||||
),
|
||||
|
||||
@@ -11,6 +11,7 @@ import 'package:eatvio_app/core/widgets/success_published_diet_bottom_sheet.dart
|
||||
import 'package:eatvio_app/features/create_diet/presentation/widgets/error_widget.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/feed/models/plan_pagination_model.dart';
|
||||
import 'package:eatvio_app/features/create_plan/presentation/widgets/option_widget.dart';
|
||||
import 'package:eatvio_app/features/create_plan/presentation/widgets/options_count_button.dart';
|
||||
import 'package:eatvio_app/features/create_plan/presentation/widgets/plan_duration_drop_down_widget.dart';
|
||||
@@ -25,11 +26,23 @@ import 'package:go_router/go_router.dart';
|
||||
class CreatePlanScreen extends StatelessWidget {
|
||||
static const name = 'createPlanScreen';
|
||||
static const path = '/create-Plan-screen';
|
||||
const CreatePlanScreen({super.key});
|
||||
const CreatePlanScreen({super.key, this.plan});
|
||||
|
||||
final PlanModel? plan;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocProvider(create: (context) => sl<CreatePlanCubit>(), child: const CreatePlanScreenContent());
|
||||
return BlocProvider(
|
||||
create: (context) {
|
||||
final cubit = sl<CreatePlanCubit>();
|
||||
final existingPlan = plan;
|
||||
if (existingPlan != null) {
|
||||
cubit.loadExisting(existingPlan);
|
||||
}
|
||||
return cubit;
|
||||
},
|
||||
child: const CreatePlanScreenContent(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,17 +60,19 @@ class _CreatePlanScreenContentState extends State<CreatePlanScreenContent> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final s = S.of(context);
|
||||
final isEditing = context.read<CreatePlanCubit>().sourcePlan != null;
|
||||
return BlocBuilder<CreatePlanCubit, CreatePlanState>(
|
||||
buildWhen: (previous, current) => previous.planCreated != current.planCreated || previous.isLoading != current.isLoading,
|
||||
builder: (context, state) {
|
||||
if (state.planCreated) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!context.mounted) return;
|
||||
final isEditing = context.read<CreatePlanCubit>().sourcePlan != null;
|
||||
context.pop(true);
|
||||
showBottomSheetWithBlur(
|
||||
userRootNavigator: true,
|
||||
context: context,
|
||||
child: SuccessPublishedDietOrPlanBottomSheet(title: state.planData.name ?? 'План', isPlan: true),
|
||||
child: SuccessPublishedDietOrPlanBottomSheet(title: state.planData.name ?? 'План', isPlan: true, isUpdated: isEditing),
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -68,7 +83,7 @@ class _CreatePlanScreenContentState extends State<CreatePlanScreenContent> {
|
||||
appBar: AppBar(
|
||||
centerTitle: true,
|
||||
title: Text(
|
||||
s.adding_plan,
|
||||
isEditing ? 'Редактирование плана' : s.adding_plan,
|
||||
textAlign: TextAlign.center,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
softWrap: true,
|
||||
@@ -102,7 +117,9 @@ class _CreatePlanScreenContentState extends State<CreatePlanScreenContent> {
|
||||
return Column(
|
||||
children: [
|
||||
CustomTextFormField(
|
||||
key: const ValueKey('plan-name'),
|
||||
hintText: s.plan_label_desc,
|
||||
initialValue: state.planData.name,
|
||||
hasError: state.validationState.titleHasError,
|
||||
onChanged: (value) {
|
||||
context.read<CreatePlanCubit>().setName(value);
|
||||
@@ -144,6 +161,7 @@ class _CreatePlanScreenContentState extends State<CreatePlanScreenContent> {
|
||||
children: [
|
||||
MarkdownEditor(
|
||||
key: _mdKey,
|
||||
initialText: state.planData.details,
|
||||
hintText: s.plan_desc_example,
|
||||
hasError: state.validationState.descriptionHasError,
|
||||
minLines: 6,
|
||||
@@ -230,7 +248,7 @@ class _CreatePlanScreenContentState extends State<CreatePlanScreenContent> {
|
||||
onPressed: () async {
|
||||
await context.read<CreatePlanCubit>().publishPlan();
|
||||
},
|
||||
text: s.publish,
|
||||
text: isEditing ? 'Обновить' : s.publish,
|
||||
height: 52,
|
||||
buttonTextStyle: TextStyles.textMSemibold.copyWith(color: appTheme.whiteA700),
|
||||
buttonStyle: ButtonStyle(
|
||||
|
||||
@@ -4,16 +4,57 @@ import 'dart:io';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:eatvio_app/core/const.dart';
|
||||
import 'package:eatvio_app/core/image_compressing/image_compress_utility.dart';
|
||||
import 'package:eatvio_app/core/utils/toasts.dart';
|
||||
import 'package:eatvio_app/features/create_plan/presentation/cubit/create_plan_state.dart';
|
||||
import 'package:eatvio_app/features/create_plan/repositories/create_plan_repository.dart';
|
||||
import 'package:eatvio_app/features/feed/models/plan_pagination_model.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:eatvio_app/core/error_reporting/report_from.dart';
|
||||
import 'package:flutter_image_compress/flutter_image_compress.dart';
|
||||
|
||||
class CreatePlanCubit extends Cubit<CreatePlanState> {
|
||||
final CreatePlanRepository createPlanRepository;
|
||||
PlanModel? sourcePlan;
|
||||
|
||||
CreatePlanCubit({required this.createPlanRepository}) : super(CreatePlanState.initial());
|
||||
|
||||
void loadExisting(PlanModel plan) {
|
||||
sourcePlan = plan;
|
||||
final durationIndex = durationValues.indexOf(plan.duration);
|
||||
emit(
|
||||
state.copyWith(
|
||||
durationIndex: durationIndex >= 0 ? durationIndex : null,
|
||||
selectedOptionIndex: plan.packetsCount == 3 ? 0 : null,
|
||||
planData: state.planData.copyWith(
|
||||
name: plan.name,
|
||||
details: plan.details,
|
||||
duration: plan.duration,
|
||||
state: plan.state,
|
||||
type: [
|
||||
plan.type.slim ?? false,
|
||||
plan.type.fit ?? false,
|
||||
plan.type.wellness ?? false,
|
||||
plan.type.behavior ?? false,
|
||||
],
|
||||
options: [
|
||||
_packetOrEmpty(plan.packet1Options),
|
||||
plan.packetsCount == 3 ? _packetOrEmpty(plan.packet2Options) : PacketOptions.empty(),
|
||||
plan.packetsCount == 3 ? _packetOrEmpty(plan.packet3Options) : PacketOptions.empty(),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
PacketOptions _packetOrEmpty(PacketOptions? option) {
|
||||
if (option == null) {
|
||||
return PacketOptions.empty();
|
||||
}
|
||||
final price = option.price;
|
||||
final normalizedPrice = price is num ? price.toDouble() : double.tryParse('$price') ?? 1200;
|
||||
return option.copyWith(price: normalizedPrice);
|
||||
}
|
||||
|
||||
void setDetails(String text) {
|
||||
emit(state.copyWith(planData: state.planData.copyWith(details: text)));
|
||||
validateDetails();
|
||||
@@ -147,7 +188,7 @@ class CreatePlanCubit extends Cubit<CreatePlanState> {
|
||||
}
|
||||
|
||||
bool validateImage() {
|
||||
if (state.planData.image == null) {
|
||||
if (state.planData.image == null && sourcePlan == null) {
|
||||
emit(state.copyWith.validationState(imageHasError: true));
|
||||
return false;
|
||||
}
|
||||
@@ -413,31 +454,34 @@ class CreatePlanCubit extends Cubit<CreatePlanState> {
|
||||
'type[fit]': state.planData.type[1] ? '1' : '0',
|
||||
'type[wellness]': state.planData.type[2] ? '1' : '0',
|
||||
'type[behavior]': state.planData.type[3] ? '1' : '0',
|
||||
'image': state.planData.image != null
|
||||
? await MultipartFile.fromFile((await ImageCompressUtility.compressFile(File(state.planData.image!.path))).path,
|
||||
filename: state.planData.image!.name)
|
||||
: null
|
||||
});
|
||||
if (state.planData.image != null) {
|
||||
final image = state.planData.image!;
|
||||
planMap['image'] = await MultipartFile.fromFile(
|
||||
(await ImageCompressUtility.compressFile(File(image.path))).path,
|
||||
filename: image.name,
|
||||
);
|
||||
}
|
||||
if (state.planData.options[1].short.isNotEmpty) {
|
||||
planMap.addAll({
|
||||
'packets_count': 3,
|
||||
'packet1_options': {
|
||||
'short': state.planData.options[0].short,
|
||||
'price': int.parse((state.planData.options[0].price as double).toStringAsFixed(0)),
|
||||
'price': _optionPrice(state.planData.options[0].price),
|
||||
'description_md': state.planData.options[0].description,
|
||||
'items': Map.fromIterable(state.planData.options[0].items),
|
||||
'has_personal_diet': state.planData.options[0].hasPersonalDiet ? '1' : '0',
|
||||
},
|
||||
'packet2_options': {
|
||||
'short': state.planData.options[1].short,
|
||||
'price': int.parse((state.planData.options[1].price as double).toStringAsFixed(0)),
|
||||
'price': _optionPrice(state.planData.options[1].price),
|
||||
'description_md': state.planData.options[1].description,
|
||||
'items': Map.fromIterable(state.planData.options[1].items),
|
||||
'has_personal_diet': state.planData.options[1].hasPersonalDiet ? '1' : '0',
|
||||
},
|
||||
'packet3_options': {
|
||||
'short': state.planData.options[2].short,
|
||||
'price': int.parse((state.planData.options[2].price as double).toStringAsFixed(0)),
|
||||
'price': _optionPrice(state.planData.options[2].price),
|
||||
'description_md': state.planData.options[2].description,
|
||||
'items': Map.fromIterable(state.planData.options[2].items),
|
||||
'has_personal_diet': state.planData.options[2].hasPersonalDiet ? '1' : '0',
|
||||
@@ -448,20 +492,34 @@ class CreatePlanCubit extends Cubit<CreatePlanState> {
|
||||
'packets_count': 1,
|
||||
'packet1_options': {
|
||||
'short': state.planData.options[0].short,
|
||||
'price': int.parse((state.planData.options[0].price as double).toStringAsFixed(0)),
|
||||
'price': _optionPrice(state.planData.options[0].price),
|
||||
'description_md': state.planData.options[0].description,
|
||||
'items': Map.fromIterable(state.planData.options[0].items),
|
||||
'has_personal_diet': state.planData.options[0].hasPersonalDiet ? '1' : '0',
|
||||
},
|
||||
});
|
||||
}
|
||||
await createPlanRepository.createPlan(model: planMap);
|
||||
final editingPlan = sourcePlan;
|
||||
if (editingPlan == null) {
|
||||
await createPlanRepository.createPlan(model: planMap);
|
||||
} else {
|
||||
await createPlanRepository.updatePublishedPlan(slug: editingPlan.slug, model: planMap);
|
||||
}
|
||||
emit(state.copyWith(isLoading: false, planCreated: true));
|
||||
}
|
||||
} catch (e, st) {
|
||||
await reportFrom(e, st, this);
|
||||
log(e.toString());
|
||||
emit(state.copyWith(isLoading: false, planCreated: true));
|
||||
emit(state.copyWith(isLoading: false, planCreated: false));
|
||||
final isForbidden = e is DioException && e.response?.statusCode == 403;
|
||||
showErrorToast(isForbidden ? 'Редактирование недоступно: клиент уже начал проходить план' : 'Не удалось сохранить план');
|
||||
}
|
||||
}
|
||||
|
||||
int _optionPrice(dynamic price) {
|
||||
if (price is num) {
|
||||
return price.round();
|
||||
}
|
||||
return int.parse(price.toString());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:dotted_border/dotted_border.dart';
|
||||
import 'package:eatvio_app/core/app_config.dart';
|
||||
import 'package:eatvio_app/core/app_router.dart';
|
||||
import 'package:eatvio_app/core/locator_service.dart';
|
||||
import 'package:eatvio_app/core/permission_handler/permission_handler.dart';
|
||||
import 'package:eatvio_app/core/theme/custom_text_style.dart';
|
||||
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
||||
@@ -91,6 +93,18 @@ class _PlanImagePickerWidgetState extends State<PlanImagePickerWidget> {
|
||||
final s = S.of(context);
|
||||
return BlocBuilder<CreatePlanCubit, CreatePlanState>(
|
||||
builder: (context, state) {
|
||||
final selectedImage = state.planData.image;
|
||||
final existingImage = context.read<CreatePlanCubit>().sourcePlan?.image;
|
||||
final hasCover = selectedImage != null || (existingImage != null && existingImage.isNotEmpty);
|
||||
final ImageProvider? coverImage;
|
||||
if (selectedImage != null) {
|
||||
coverImage = FileImage(File(selectedImage.path));
|
||||
} else if (existingImage != null && existingImage.isNotEmpty) {
|
||||
final url = existingImage.startsWith('http') ? existingImage : '${sl<AppConfig>().baseUrl}/storage/$existingImage';
|
||||
coverImage = NetworkImage(url);
|
||||
} else {
|
||||
coverImage = null;
|
||||
}
|
||||
return Column(
|
||||
children: [
|
||||
GestureDetector(
|
||||
@@ -101,12 +115,12 @@ class _PlanImagePickerWidgetState extends State<PlanImagePickerWidget> {
|
||||
height: 240,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
image: state.planData.image != null ? DecorationImage(fit: BoxFit.cover, image: FileImage(File(state.planData.image?.path ?? ''))) : null,
|
||||
image: coverImage != null ? DecorationImage(fit: BoxFit.cover, image: coverImage) : null,
|
||||
color: appTheme.gray50,
|
||||
),
|
||||
width: MediaQuery.sizeOf(context).width,
|
||||
padding: state.planData.image != null ? null : const EdgeInsets.all(8),
|
||||
child: state.planData.image != null
|
||||
padding: hasCover ? null : const EdgeInsets.all(8),
|
||||
child: selectedImage != null
|
||||
? Padding(
|
||||
padding: const EdgeInsets.only(top: 6, right: 6),
|
||||
child: GestureDetector(
|
||||
@@ -131,6 +145,8 @@ class _PlanImagePickerWidgetState extends State<PlanImagePickerWidget> {
|
||||
),
|
||||
),
|
||||
)
|
||||
: hasCover
|
||||
? const SizedBox.shrink()
|
||||
: Stack(
|
||||
children: [
|
||||
DottedBorder(
|
||||
|
||||
@@ -11,7 +11,13 @@ class CreatePlanRepository {
|
||||
}) async {
|
||||
final formData = FormData.fromMap(model);
|
||||
return await createPlanService.createPlan(formData: formData);
|
||||
|
||||
}
|
||||
|
||||
|
||||
Future<void> updatePublishedPlan({
|
||||
required String slug,
|
||||
required Map<String, dynamic> model,
|
||||
}) async {
|
||||
final formData = FormData.fromMap(model);
|
||||
return await createPlanService.updatePublishedPlan(slug: slug, formData: formData);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,5 +12,11 @@ abstract class CreatePlanService {
|
||||
Future<void> createPlan({
|
||||
@Body() required FormData formData,
|
||||
});
|
||||
|
||||
|
||||
@POST('/api/plans/update/published/{slug}')
|
||||
@MultiPart()
|
||||
Future<void> updatePublishedPlan({
|
||||
@Path() required String slug,
|
||||
@Body() required FormData formData,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'package:eatvio_app/features/diary/entities/diary_day_entity.dart';
|
||||
import 'package:eatvio_app/features/diary/entities/diary_future_diet_day_entity.dart';
|
||||
import 'package:eatvio_app/features/diary/models/consultant_review_model.dart';
|
||||
import 'package:eatvio_app/features/diary/models/nutrition_model.dart';
|
||||
import 'package:eatvio_app/features/diary/presentation/diary_edit_access.dart';
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
part 'diary_state.freezed.dart';
|
||||
@@ -12,7 +13,8 @@ abstract class DiaryState with _$DiaryState {
|
||||
|
||||
const factory DiaryState.loading() = DiaryStateLoading;
|
||||
|
||||
const factory DiaryState.error({required String errorMessage}) = DiaryStateError;
|
||||
const factory DiaryState.error({required String errorMessage}) =
|
||||
DiaryStateError;
|
||||
|
||||
const factory DiaryState.loaded({
|
||||
required DiaryDayEntity diary,
|
||||
@@ -31,6 +33,19 @@ abstract class DiaryState with _$DiaryState {
|
||||
@Default(false) bool isLoadingAddingProductInMeal,
|
||||
}) = DiaryStateLoaded;
|
||||
|
||||
const factory DiaryState.loadedTomorrowWithDiet({required DiaryFutureDietDayEntity diary, required DateTime selectedDay, required int selectedMeal}) =
|
||||
DiaryStateLoadedTomorrowWithDiet;
|
||||
const factory DiaryState.loadedTomorrowWithDiet({
|
||||
required DiaryFutureDietDayEntity diary,
|
||||
required DateTime selectedDay,
|
||||
required int selectedMeal,
|
||||
}) = DiaryStateLoadedTomorrowWithDiet;
|
||||
}
|
||||
|
||||
extension DiaryStateLoadedEditAccess on DiaryStateLoaded {
|
||||
bool get canEditDiary => canEditDiaryDay(
|
||||
isConsultantView: isConsultantView,
|
||||
isDiaryCommented: isDiaryCommented,
|
||||
isDiaryFinished: isDiaryFinished,
|
||||
closed: diary.closed,
|
||||
diaryDate: diary.date,
|
||||
);
|
||||
}
|
||||
|
||||
38
lib/features/diary/presentation/diary_edit_access.dart
Normal file
38
lib/features/diary/presentation/diary_edit_access.dart
Normal file
@@ -0,0 +1,38 @@
|
||||
/// Finished diaries stay editable for this many calendar days back from today,
|
||||
/// including today. Older finished days stay read-only.
|
||||
const int diaryFinishedEditWindowInDays = 7;
|
||||
|
||||
bool canEditDiaryDay({
|
||||
required bool isConsultantView,
|
||||
required bool isDiaryCommented,
|
||||
required bool isDiaryFinished,
|
||||
required int closed,
|
||||
required String diaryDate,
|
||||
DateTime? now,
|
||||
}) {
|
||||
if (isConsultantView || isDiaryCommented || closed != 0) {
|
||||
return false;
|
||||
}
|
||||
if (!isDiaryFinished) {
|
||||
return true;
|
||||
}
|
||||
final day = _calendarDate(diaryDate);
|
||||
if (day == null) {
|
||||
return false;
|
||||
}
|
||||
final clock = now ?? DateTime.now();
|
||||
final today = DateTime(clock.year, clock.month, clock.day);
|
||||
final oldestEditableDay = today.subtract(
|
||||
const Duration(days: diaryFinishedEditWindowInDays),
|
||||
);
|
||||
return !day.isBefore(oldestEditableDay) && !day.isAfter(today);
|
||||
}
|
||||
|
||||
DateTime? _calendarDate(String raw) {
|
||||
final parsed = DateTime.tryParse(raw);
|
||||
if (parsed == null) {
|
||||
return null;
|
||||
}
|
||||
final local = parsed.toLocal();
|
||||
return DateTime(local.year, local.month, local.day);
|
||||
}
|
||||
@@ -74,7 +74,12 @@ class _DiaryScreenContentState extends State<DiaryScreenContent> {
|
||||
}
|
||||
if (state is DiaryStateError) {
|
||||
return Scaffold(
|
||||
body: DataErrorWidget(onReload: () => context.read<DiaryCubit>().fetchDiaryV2(needLoading: false, diaryId: widget.diaryId)),
|
||||
body: DataErrorWidget(
|
||||
onReload: () => context.read<DiaryCubit>().fetchDiaryV2(
|
||||
needLoading: false,
|
||||
diaryId: widget.diaryId,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -84,8 +89,14 @@ class _DiaryScreenContentState extends State<DiaryScreenContent> {
|
||||
? AppBar(toolbarHeight: 0)
|
||||
: AppBar(
|
||||
title: Text(
|
||||
state is DiaryStateLoaded ? DateTime.parse(state.diary.date).toLocal().format('d MMMM') : '',
|
||||
style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black),
|
||||
state is DiaryStateLoaded
|
||||
? DateTime.parse(
|
||||
state.diary.date,
|
||||
).toLocal().format('d MMMM')
|
||||
: '',
|
||||
style: TextStyles.headingXLSemibold.copyWith(
|
||||
color: appTheme.black,
|
||||
),
|
||||
),
|
||||
centerTitle: true,
|
||||
),
|
||||
@@ -95,26 +106,39 @@ class _DiaryScreenContentState extends State<DiaryScreenContent> {
|
||||
child: Column(
|
||||
children: [
|
||||
if (widget.diaryId == null) CalendarWidget(date: widget.date),
|
||||
if (state is DiaryStateLoading) ...[const SizedBox(height: 60), const Loader()],
|
||||
if (state is DiaryStateLoading) ...[
|
||||
const SizedBox(height: 60),
|
||||
const Loader(),
|
||||
],
|
||||
if (state is DiaryStateLoaded)
|
||||
WakeupAndRetireWidget(
|
||||
isEnabled: state.diary.typeOfDiary.hasChosenPlan || state.isConsultantView || state.isWakeUpAndRetireEnabled,
|
||||
disable: state.isConsultantView || state.isDiaryFinished || state.isDiaryCommented,
|
||||
isEnabled:
|
||||
state.diary.typeOfDiary.hasChosenPlan ||
|
||||
state.isConsultantView ||
|
||||
state.isWakeUpAndRetireEnabled,
|
||||
disable: !state.canEditDiary,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
if (state is DiaryStateLoaded)
|
||||
MealWidget(
|
||||
showMood: state.diary.typeOfDiary.hasChosenPlan || state.isConsultantView || state.isMoodEnabled,
|
||||
showAddButton: !state.isConsultantView && !state.isDiaryFinished && !state.isDiaryCommented,
|
||||
showMood:
|
||||
state.diary.typeOfDiary.hasChosenPlan ||
|
||||
state.isConsultantView ||
|
||||
state.isMoodEnabled,
|
||||
showAddButton: state.canEditDiary,
|
||||
),
|
||||
if (state is DiaryStateLoadedTomorrowWithDiet) const MealWidget(showMood: false, showAddButton: false),
|
||||
if (state is DiaryStateLoadedTomorrowWithDiet)
|
||||
const MealWidget(showMood: false, showAddButton: false),
|
||||
if (state is DiaryStateLoaded) const CaloriesChartWidget(),
|
||||
const SizedBox(height: 12),
|
||||
Builder(
|
||||
builder: (context) {
|
||||
if (state is DiaryStateLoaded && state.diary.typeOfDiary.hasChosenPlan) {
|
||||
if (state is DiaryStateLoaded &&
|
||||
state.diary.typeOfDiary.hasChosenPlan) {
|
||||
if (state.diary.comment.isNotEmpty) {
|
||||
return CommentForConsultantWidget(comment: state.diary.comment);
|
||||
return CommentForConsultantWidget(
|
||||
comment: state.diary.comment,
|
||||
);
|
||||
}
|
||||
if (state.isDiaryCommented || state.isDiaryFinished) {
|
||||
return const SizedBox.shrink();
|
||||
@@ -124,7 +148,10 @@ class _DiaryScreenContentState extends State<DiaryScreenContent> {
|
||||
return const SizedBox.shrink();
|
||||
},
|
||||
),
|
||||
if (state is DiaryStateLoaded && !state.isConsultantView && !state.isDiaryFinished && !state.isDiaryCommented)
|
||||
if (state is DiaryStateLoaded &&
|
||||
!state.isConsultantView &&
|
||||
!state.isDiaryFinished &&
|
||||
!state.isDiaryCommented)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 12),
|
||||
child: _isDiaryFinishing
|
||||
@@ -135,11 +162,13 @@ class _DiaryScreenContentState extends State<DiaryScreenContent> {
|
||||
setState(() {
|
||||
_isDiaryFinishing = true;
|
||||
});
|
||||
final isFinished = await context.read<DiaryCubit>().finishDiaryDay(
|
||||
diaryId: state.diary.id,
|
||||
wakeUp: state.diary.wakeup.toString(),
|
||||
retire: state.diary.retire.toString(),
|
||||
);
|
||||
final isFinished = await context
|
||||
.read<DiaryCubit>()
|
||||
.finishDiaryDay(
|
||||
diaryId: state.diary.id,
|
||||
wakeUp: state.diary.wakeup.toString(),
|
||||
retire: state.diary.retire.toString(),
|
||||
);
|
||||
if (!isFinished && context.mounted) {
|
||||
showErrorDialog(context);
|
||||
}
|
||||
@@ -149,33 +178,64 @@ class _DiaryScreenContentState extends State<DiaryScreenContent> {
|
||||
},
|
||||
),
|
||||
),
|
||||
if (state is DiaryStateLoaded && state.isConsultantView && !state.isDiaryCommented) ...[
|
||||
if (state is DiaryStateLoaded &&
|
||||
state.isConsultantView &&
|
||||
!state.isDiaryCommented) ...[
|
||||
const SizedBox(height: 16),
|
||||
const MainBackgroundBouble(padding: EdgeInsets.all(16), child: DayCommentWidget()),
|
||||
const MainBackgroundBouble(
|
||||
padding: EdgeInsets.all(16),
|
||||
child: DayCommentWidget(),
|
||||
),
|
||||
],
|
||||
if (state is DiaryStateLoaded && state.consultantReview?.consultantComments.dayComment != null) ...[
|
||||
if (state is DiaryStateLoaded &&
|
||||
state.consultantReview?.consultantComments.dayComment !=
|
||||
null) ...[
|
||||
const SizedBox(height: 12),
|
||||
MainBackgroundBouble(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
children: [
|
||||
Text(s.dayComment, style: TextStyles.textLSemibold.copyWith(color: appTheme.black)),
|
||||
Text(
|
||||
s.dayComment,
|
||||
style: TextStyles.textLSemibold.copyWith(
|
||||
color: appTheme.black,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
CommentContainer(comment: state.consultantReview?.consultantComments.dayComment ?? ''),
|
||||
CommentContainer(
|
||||
comment:
|
||||
state
|
||||
.consultantReview
|
||||
?.consultantComments
|
||||
.dayComment ??
|
||||
'',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 28),
|
||||
],
|
||||
if (state is DiaryStateLoaded && state.isConsultantView && state.isDiaryFinished && !state.isDiaryCommented) ...[
|
||||
if (state is DiaryStateLoaded &&
|
||||
state.isConsultantView &&
|
||||
state.isDiaryFinished &&
|
||||
!state.isDiaryCommented) ...[
|
||||
const SizedBox(height: 16),
|
||||
CustomOutlinedButton(
|
||||
text: s.send,
|
||||
height: 52,
|
||||
buttonTextStyle: TextStyles.textMSemibold.copyWith(color: appTheme.whiteA700),
|
||||
buttonStyle: ButtonStyle(side: const WidgetStatePropertyAll(BorderSide.none), backgroundColor: WidgetStatePropertyAll(appTheme.blueA400)),
|
||||
buttonTextStyle: TextStyles.textMSemibold.copyWith(
|
||||
color: appTheme.whiteA700,
|
||||
),
|
||||
buttonStyle: ButtonStyle(
|
||||
side: const WidgetStatePropertyAll(BorderSide.none),
|
||||
backgroundColor: WidgetStatePropertyAll(
|
||||
appTheme.blueA400,
|
||||
),
|
||||
),
|
||||
onPressed: () async {
|
||||
await context.read<ConsultantCommentsCubit>().submitComments(state.diary.id);
|
||||
await context
|
||||
.read<ConsultantCommentsCubit>()
|
||||
.submitComments(state.diary.id);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
@@ -26,7 +26,8 @@ class _MoodRatingWidgetState extends State<MoodRatingWidget> {
|
||||
final meals = state.diary.meals;
|
||||
if (selectedIndex >= 0 && selectedIndex < meals.length) {
|
||||
if (isFirstFetch[selectedIndex]) {
|
||||
currentSelectedRating[selectedIndex] = meals[selectedIndex].mood.toDouble() + 1;
|
||||
currentSelectedRating[selectedIndex] =
|
||||
meals[selectedIndex].mood.toDouble() + 1;
|
||||
isFirstFetch[selectedIndex] = false;
|
||||
}
|
||||
}
|
||||
@@ -36,21 +37,30 @@ class _MoodRatingWidgetState extends State<MoodRatingWidget> {
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
state.isConsultantView ? 'Настроение пользователя после приема пищи' : 'Ваше настроение после приёма пищи',
|
||||
state.isConsultantView
|
||||
? 'Настроение пользователя после приема пищи'
|
||||
: 'Ваше настроение после приёма пищи',
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(fontSize: 16),
|
||||
),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
SvgPicture.asset('assets/icons/mood_1.svg', height: 32, width: 32),
|
||||
SvgPicture.asset(
|
||||
'assets/icons/mood_1.svg',
|
||||
height: 32,
|
||||
width: 32,
|
||||
),
|
||||
RatingBar.builder(
|
||||
ignoreGestures: state.isConsultantView || state.isDiaryFinished || state.isDiaryCommented,
|
||||
initialRating: (selectedIndex >= 0 && selectedIndex < state.diary.meals.length)
|
||||
ignoreGestures: !state.canEditDiary,
|
||||
initialRating:
|
||||
(selectedIndex >= 0 &&
|
||||
selectedIndex < state.diary.meals.length)
|
||||
? state.diary.meals[selectedIndex].mood.toDouble() + 1
|
||||
: 3,
|
||||
itemCount: 6,
|
||||
itemSize: MediaQuery.of(context).size.width / 6 - (124 / 6),
|
||||
itemSize:
|
||||
MediaQuery.of(context).size.width / 6 - (124 / 6),
|
||||
updateOnDrag: true,
|
||||
itemBuilder: (context, index) {
|
||||
switch (index) {
|
||||
@@ -58,82 +68,154 @@ class _MoodRatingWidgetState extends State<MoodRatingWidget> {
|
||||
return Container(
|
||||
width: MediaQuery.of(context).size.width / 6,
|
||||
height: 24,
|
||||
margin: currentSelectedRating[selectedIndex] == 1 ? const EdgeInsets.symmetric(horizontal: 2) : null,
|
||||
margin: currentSelectedRating[selectedIndex] == 1
|
||||
? const EdgeInsets.symmetric(horizontal: 2)
|
||||
: null,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFC82222),
|
||||
border: currentSelectedRating[selectedIndex] == 1
|
||||
? Border.all(width: 2, color: const Color(0xFFAA3333), strokeAlign: BorderSide.strokeAlignOutside)
|
||||
border:
|
||||
currentSelectedRating[selectedIndex] == 1
|
||||
? Border.all(
|
||||
width: 2,
|
||||
color: const Color(0xFFAA3333),
|
||||
strokeAlign:
|
||||
BorderSide.strokeAlignOutside,
|
||||
)
|
||||
: null,
|
||||
borderRadius: currentSelectedRating[selectedIndex] == 1
|
||||
? const BorderRadius.horizontal(left: Radius.circular(8), right: Radius.circular(2))
|
||||
: const BorderRadius.horizontal(left: Radius.circular(8)),
|
||||
borderRadius:
|
||||
currentSelectedRating[selectedIndex] == 1
|
||||
? const BorderRadius.horizontal(
|
||||
left: Radius.circular(8),
|
||||
right: Radius.circular(2),
|
||||
)
|
||||
: const BorderRadius.horizontal(
|
||||
left: Radius.circular(8),
|
||||
),
|
||||
),
|
||||
);
|
||||
case 1:
|
||||
return Container(
|
||||
width: MediaQuery.of(context).size.width / 6,
|
||||
height: 24,
|
||||
margin: currentSelectedRating[selectedIndex] == 2 ? const EdgeInsets.symmetric(horizontal: 2) : null,
|
||||
margin: currentSelectedRating[selectedIndex] == 2
|
||||
? const EdgeInsets.symmetric(horizontal: 2)
|
||||
: null,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFFF3E3E),
|
||||
border: currentSelectedRating[selectedIndex] == 2
|
||||
? Border.all(width: 2, color: const Color(0xFF550000), strokeAlign: BorderSide.strokeAlignOutside)
|
||||
border:
|
||||
currentSelectedRating[selectedIndex] == 2
|
||||
? Border.all(
|
||||
width: 2,
|
||||
color: const Color(0xFF550000),
|
||||
strokeAlign:
|
||||
BorderSide.strokeAlignOutside,
|
||||
)
|
||||
: null,
|
||||
borderRadius:
|
||||
currentSelectedRating[selectedIndex] == 2
|
||||
? BorderRadius.circular(2)
|
||||
: null,
|
||||
borderRadius: currentSelectedRating[selectedIndex] == 2 ? BorderRadius.circular(2) : null,
|
||||
),
|
||||
);
|
||||
case 2:
|
||||
return Container(
|
||||
width: MediaQuery.of(context).size.width / 6,
|
||||
height: 24,
|
||||
margin: currentSelectedRating[selectedIndex] == 3 ? const EdgeInsets.symmetric(horizontal: 2) : null,
|
||||
margin: currentSelectedRating[selectedIndex] == 3
|
||||
? const EdgeInsets.symmetric(horizontal: 2)
|
||||
: null,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFF8C926),
|
||||
border: currentSelectedRating[selectedIndex] == 3
|
||||
? Border.all(width: 2, color: const Color(0xFF8C5D00), strokeAlign: BorderSide.strokeAlignOutside)
|
||||
border:
|
||||
currentSelectedRating[selectedIndex] == 3
|
||||
? Border.all(
|
||||
width: 2,
|
||||
color: const Color(0xFF8C5D00),
|
||||
strokeAlign:
|
||||
BorderSide.strokeAlignOutside,
|
||||
)
|
||||
: null,
|
||||
borderRadius:
|
||||
currentSelectedRating[selectedIndex] == 3
|
||||
? BorderRadius.circular(2)
|
||||
: null,
|
||||
borderRadius: currentSelectedRating[selectedIndex] == 3 ? BorderRadius.circular(2) : null,
|
||||
),
|
||||
);
|
||||
case 3:
|
||||
return Container(
|
||||
width: MediaQuery.of(context).size.width / 6,
|
||||
height: 24,
|
||||
margin: currentSelectedRating[selectedIndex] == 4 ? const EdgeInsets.symmetric(horizontal: 2) : null,
|
||||
margin: currentSelectedRating[selectedIndex] == 4
|
||||
? const EdgeInsets.symmetric(horizontal: 2)
|
||||
: null,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFFFF3BF),
|
||||
border: currentSelectedRating[selectedIndex] == 4
|
||||
? Border.all(width: 2, color: const Color(0xFFB5911B), strokeAlign: BorderSide.strokeAlignOutside)
|
||||
border:
|
||||
currentSelectedRating[selectedIndex] == 4
|
||||
? Border.all(
|
||||
width: 2,
|
||||
color: const Color(0xFFB5911B),
|
||||
strokeAlign:
|
||||
BorderSide.strokeAlignOutside,
|
||||
)
|
||||
: null,
|
||||
borderRadius:
|
||||
currentSelectedRating[selectedIndex] == 4
|
||||
? BorderRadius.circular(2)
|
||||
: null,
|
||||
borderRadius: currentSelectedRating[selectedIndex] == 4 ? BorderRadius.circular(2) : null,
|
||||
),
|
||||
);
|
||||
case 4:
|
||||
return Container(
|
||||
width: MediaQuery.of(context).size.width / 6,
|
||||
height: 24,
|
||||
margin: currentSelectedRating[selectedIndex] == 5 ? const EdgeInsets.symmetric(horizontal: 2) : null,
|
||||
margin: currentSelectedRating[selectedIndex] == 5
|
||||
? const EdgeInsets.symmetric(horizontal: 2)
|
||||
: null,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF62E833),
|
||||
border: currentSelectedRating[selectedIndex] == 5
|
||||
? Border.all(width: 2, color: const Color(0xFF276918), strokeAlign: BorderSide.strokeAlignOutside)
|
||||
border:
|
||||
currentSelectedRating[selectedIndex] == 5
|
||||
? Border.all(
|
||||
width: 2,
|
||||
color: const Color(0xFF276918),
|
||||
strokeAlign:
|
||||
BorderSide.strokeAlignOutside,
|
||||
)
|
||||
: null,
|
||||
borderRadius:
|
||||
currentSelectedRating[selectedIndex] == 5
|
||||
? BorderRadius.circular(2)
|
||||
: null,
|
||||
borderRadius: currentSelectedRating[selectedIndex] == 5 ? BorderRadius.circular(2) : null,
|
||||
),
|
||||
);
|
||||
default:
|
||||
return Container(
|
||||
width: MediaQuery.of(context).size.width / 6,
|
||||
height: 24,
|
||||
margin: currentSelectedRating[selectedIndex] == 6 ? const EdgeInsets.symmetric(horizontal: 2) : null,
|
||||
margin: currentSelectedRating[selectedIndex] == 6
|
||||
? const EdgeInsets.symmetric(horizontal: 2)
|
||||
: null,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF4DCE20),
|
||||
border: currentSelectedRating[selectedIndex] == 6
|
||||
? Border.all(width: 2, color: const Color(0xFF184D12), strokeAlign: BorderSide.strokeAlignOutside)
|
||||
border:
|
||||
currentSelectedRating[selectedIndex] == 6
|
||||
? Border.all(
|
||||
width: 2,
|
||||
color: const Color(0xFF184D12),
|
||||
strokeAlign:
|
||||
BorderSide.strokeAlignOutside,
|
||||
)
|
||||
: null,
|
||||
borderRadius: currentSelectedRating[selectedIndex] == 6
|
||||
? const BorderRadius.horizontal(left: Radius.circular(2), right: Radius.circular(8))
|
||||
: const BorderRadius.horizontal(right: Radius.circular(8)),
|
||||
borderRadius:
|
||||
currentSelectedRating[selectedIndex] == 6
|
||||
? const BorderRadius.horizontal(
|
||||
left: Radius.circular(2),
|
||||
right: Radius.circular(8),
|
||||
)
|
||||
: const BorderRadius.horizontal(
|
||||
right: Radius.circular(8),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -142,12 +224,25 @@ class _MoodRatingWidgetState extends State<MoodRatingWidget> {
|
||||
setState(() {
|
||||
currentSelectedRating[selectedIndex] = value;
|
||||
});
|
||||
EasyDebounce.debounce('change-satiety', const Duration(milliseconds: 250), () {
|
||||
context.read<DiaryCubit>().fillMood(diaryId: state.diary.id, mood: currentSelectedRating[selectedIndex].toInt() - 1);
|
||||
});
|
||||
EasyDebounce.debounce(
|
||||
'change-satiety',
|
||||
const Duration(milliseconds: 250),
|
||||
() {
|
||||
context.read<DiaryCubit>().fillMood(
|
||||
diaryId: state.diary.id,
|
||||
mood:
|
||||
currentSelectedRating[selectedIndex].toInt() -
|
||||
1,
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
SvgPicture.asset('assets/icons/mood_2.svg', height: 32, width: 32),
|
||||
SvgPicture.asset(
|
||||
'assets/icons/mood_2.svg',
|
||||
height: 32,
|
||||
width: 32,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
|
||||
@@ -52,7 +52,9 @@ class _PhotoGalleryWidgetState extends State<PhotoGalleryWidget> {
|
||||
AppRouter.navigatorKey.currentContext?.pop();
|
||||
|
||||
final picker = ImagePicker();
|
||||
XFile? image = await picker.pickImage(source: ImageSource.camera);
|
||||
XFile? image = await picker.pickImage(
|
||||
source: ImageSource.camera,
|
||||
);
|
||||
if (image != null) {
|
||||
diaryCubit.uploadPhotos(
|
||||
files: [File(image.path)],
|
||||
@@ -116,7 +118,8 @@ class _PhotoGalleryWidgetState extends State<PhotoGalleryWidget> {
|
||||
builder: (context, state) {
|
||||
if (state is DiaryStateLoaded) {
|
||||
int mealIndex = state.selectedMeal - 1;
|
||||
if (state.diary.meals[mealIndex].photos.isEmpty && !state.isConsultantView && !state.isDiaryFinished && !state.isDiaryCommented) {
|
||||
if (state.diary.meals[mealIndex].photos.isEmpty &&
|
||||
state.canEditDiary) {
|
||||
return GestureDetector(
|
||||
onTap: () async {
|
||||
showImagePicker(state.diary);
|
||||
@@ -140,10 +143,11 @@ class _PhotoGalleryWidgetState extends State<PhotoGalleryWidget> {
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: CachedNetworkImage(
|
||||
imageUrl: '${sl<AppConfig>().baseUrl}/storage/${state.diary.meals[mealIndex].photos[currentSelectedPhotoIndex].path}',
|
||||
imageUrl:
|
||||
'${sl<AppConfig>().baseUrl}/storage/${state.diary.meals[mealIndex].photos[currentSelectedPhotoIndex].path}',
|
||||
),
|
||||
),
|
||||
if (!state.isConsultantView && !state.isDiaryFinished && !state.isDiaryCommented)
|
||||
if (state.canEditDiary)
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: IconButton.filled(
|
||||
@@ -153,12 +157,24 @@ class _PhotoGalleryWidgetState extends State<PhotoGalleryWidget> {
|
||||
),
|
||||
onPressed: () async {
|
||||
await diaryCubit.deletePhoto(
|
||||
photo: state.diary.meals[mealIndex].photos[currentSelectedPhotoIndex],
|
||||
photo: state
|
||||
.diary
|
||||
.meals[mealIndex]
|
||||
.photos[currentSelectedPhotoIndex],
|
||||
diaryId: state.diary.id,
|
||||
);
|
||||
setState(() {
|
||||
if (currentSelectedPhotoIndex == state.diary.meals[mealIndex].photos.length - 1 && currentSelectedPhotoIndex != 0) {
|
||||
currentSelectedPhotoIndex = state.diary.meals[mealIndex].photos.length - 2;
|
||||
if (currentSelectedPhotoIndex ==
|
||||
state
|
||||
.diary
|
||||
.meals[mealIndex]
|
||||
.photos
|
||||
.length -
|
||||
1 &&
|
||||
currentSelectedPhotoIndex != 0) {
|
||||
currentSelectedPhotoIndex =
|
||||
state.diary.meals[mealIndex].photos.length -
|
||||
2;
|
||||
}
|
||||
});
|
||||
},
|
||||
@@ -178,8 +194,13 @@ class _PhotoGalleryWidgetState extends State<PhotoGalleryWidget> {
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
children: [
|
||||
for (int i = 0; i < state.diary.meals[mealIndex].photos.length + 1; i++) ...[
|
||||
if (i == state.diary.meals[mealIndex].photos.length && !state.isConsultantView && !state.isDiaryFinished && !state.isDiaryCommented)
|
||||
for (
|
||||
int i = 0;
|
||||
i < state.diary.meals[mealIndex].photos.length + 1;
|
||||
i++
|
||||
) ...[
|
||||
if (i == state.diary.meals[mealIndex].photos.length &&
|
||||
state.canEditDiary)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 8),
|
||||
child: InkWell(
|
||||
@@ -212,13 +233,15 @@ class _PhotoGalleryWidgetState extends State<PhotoGalleryWidget> {
|
||||
height: 52,
|
||||
width: 52,
|
||||
child: CachedNetworkImage(
|
||||
imageUrl: '${sl<AppConfig>().baseUrl}/storage/${state.diary.meals[mealIndex].photos[i].path}',
|
||||
imageUrl:
|
||||
'${sl<AppConfig>().baseUrl}/storage/${state.diary.meals[mealIndex].photos[i].path}',
|
||||
fit: BoxFit.fitWidth,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (i < state.diary.meals[mealIndex].photos.length - 1) const SizedBox(width: 8),
|
||||
if (i < state.diary.meals[mealIndex].photos.length - 1)
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
],
|
||||
),
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:dotted_border/dotted_border.dart';
|
||||
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
||||
import 'package:eatvio_app/features/consultations/models/consultation_model.dart';
|
||||
import 'package:eatvio_app/features/diary/models/consultant_review_model.dart';
|
||||
import 'package:eatvio_app/features/diary/models/diary_day_model.dart';
|
||||
import 'package:eatvio_app/features/diary/presentation/bloc/diary_cubit.dart';
|
||||
@@ -32,17 +30,26 @@ class _ProductWidgetState extends State<ProductWidget> {
|
||||
super.initState();
|
||||
diaryCubit = context.read<DiaryCubit>();
|
||||
portionName = widget.portion.portion == 'gr' ? 'гр' : 'шт';
|
||||
isEaten = widget.portion.portionState == PortionState.eatenFromDiet ? true : false;
|
||||
isEaten = widget.portion.portionState == PortionState.eatenFromDiet
|
||||
? true
|
||||
: false;
|
||||
}
|
||||
|
||||
Widget _buildNutritionalInfo() {
|
||||
if (widget.portion.productNutritionalValueId == null) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(12)),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: const Text(
|
||||
'Продукт отправлен на модерацию',
|
||||
style: TextStyle(color: Color(0xFF2F80ED), fontSize: 12, fontWeight: FontWeight.w500),
|
||||
style: TextStyle(
|
||||
color: Color(0xFF2F80ED),
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -57,7 +64,8 @@ class _ProductWidgetState extends State<ProductWidget> {
|
||||
calories = (caloriesInfo.calories * widget.portion.quantity) / 100;
|
||||
proteins = (caloriesInfo.proteins * widget.portion.quantity) / 100;
|
||||
fats = (caloriesInfo.fats * widget.portion.quantity) / 100;
|
||||
carbohydrates = (caloriesInfo.carbohydrates * widget.portion.quantity) / 100;
|
||||
carbohydrates =
|
||||
(caloriesInfo.carbohydrates * widget.portion.quantity) / 100;
|
||||
} else {
|
||||
calories = caloriesInfo.energyOnPortion;
|
||||
proteins = caloriesInfo.proteinsOnPortion;
|
||||
@@ -81,10 +89,17 @@ class _ProductWidgetState extends State<ProductWidget> {
|
||||
Widget _buildNutritionChip(String label, int value) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(8)),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
'$label $value',
|
||||
style: const TextStyle(color: Color(0xFF6D6F71), fontSize: 12, fontWeight: FontWeight.w500),
|
||||
style: const TextStyle(
|
||||
color: Color(0xFF6D6F71),
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -93,16 +108,24 @@ class _ProductWidgetState extends State<ProductWidget> {
|
||||
void didUpdateWidget(ProductWidget oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
portionName = widget.portion.portion == 'gr' ? 'гр' : 'шт';
|
||||
isEaten = widget.portion.portionState == PortionState.eatenFromDiet ? true : false;
|
||||
isEaten = widget.portion.portionState == PortionState.eatenFromDiet
|
||||
? true
|
||||
: false;
|
||||
}
|
||||
|
||||
Future updateProductInMeal(DiaryStateLoaded state, PortionModel portion) {
|
||||
return showModalBottomSheet(
|
||||
useRootNavigator: true,
|
||||
context: context,
|
||||
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(12))),
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(12)),
|
||||
),
|
||||
isScrollControlled: true,
|
||||
builder: (context) => AddProductModal(state: state, portion: portion, diaryCubit: diaryCubit),
|
||||
builder: (context) => AddProductModal(
|
||||
state: state,
|
||||
portion: portion,
|
||||
diaryCubit: diaryCubit,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -113,33 +136,36 @@ class _ProductWidgetState extends State<ProductWidget> {
|
||||
if (state is DiaryStateLoadedTomorrowWithDiet) {
|
||||
return ListTile(
|
||||
horizontalTitleGap: 0,
|
||||
contentPadding: widget.portion.fromDiet ? const EdgeInsets.only(right: 24, left: 12) : null,
|
||||
contentPadding: widget.portion.fromDiet
|
||||
? const EdgeInsets.only(right: 24, left: 12)
|
||||
: null,
|
||||
title: Text(widget.portion.productName),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('${widget.portion.quantity} ${widget.portion.portion == 'gr' ? 'гр' : 'шт'}'),
|
||||
Text(
|
||||
'${widget.portion.quantity} ${widget.portion.portion == 'gr' ? 'гр' : 'шт'}',
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
_buildNutritionalInfo(),
|
||||
],
|
||||
),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
tileColor: const Color(0xFFF6FAFF),
|
||||
);
|
||||
}
|
||||
if (state is DiaryStateLoaded) {
|
||||
final isDiaryCommented =
|
||||
state.diary.typeOfDiary.diaryState == DiaryStateEnum.commented ||
|
||||
state.diary.typeOfDiary.diaryState == DiaryStateEnum.lastDayCommented ||
|
||||
state.diary.typeOfDiary.diaryState == DiaryStateEnum.reviewedByUser;
|
||||
final isDiaryFinished =
|
||||
state.diary.typeOfDiary.diaryState == DiaryStateEnum.finished || state.diary.typeOfDiary.diaryState == DiaryStateEnum.lastDayFinished;
|
||||
final canEditPortion = !state.isConsultantView && !isDiaryCommented && !isDiaryFinished && !widget.portion.fromDiet;
|
||||
final canEditPortion = state.canEditDiary && !widget.portion.fromDiet;
|
||||
final consultantComments = state.consultantReview?.consultantComments;
|
||||
final canMarkAsEaten = !state.isConsultantView && !isDiaryCommented && !isDiaryFinished;
|
||||
final canMarkAsEaten = state.canEditDiary;
|
||||
PortionComment? portionComment;
|
||||
if (consultantComments != null) {
|
||||
portionComment = consultantComments.portionsComments.firstWhereOrNull((elem) => elem.portionId == widget.portion.id);
|
||||
portionComment = consultantComments.portionsComments
|
||||
.firstWhereOrNull(
|
||||
(elem) => elem.portionId == widget.portion.id,
|
||||
);
|
||||
}
|
||||
if (widget.portion.portionState == PortionState.eatenDeleted) {
|
||||
return DottedBorder(
|
||||
@@ -151,27 +177,41 @@ class _ProductWidgetState extends State<ProductWidget> {
|
||||
color: Color(0xFF6D6F71),
|
||||
),
|
||||
child: ListTile(
|
||||
onTap: () => diaryCubit.restore(diaryId: state.diary.id, mealId: state.selectedMeal, portionId: widget.portion.id),
|
||||
trailing: SvgPicture.asset('assets/icons/revert_product.svg'),
|
||||
onTap: state.canEditDiary
|
||||
? () => diaryCubit.restore(
|
||||
diaryId: state.diary.id,
|
||||
mealId: state.selectedMeal,
|
||||
portionId: widget.portion.id,
|
||||
)
|
||||
: null,
|
||||
trailing: state.canEditDiary
|
||||
? SvgPicture.asset('assets/icons/revert_product.svg')
|
||||
: null,
|
||||
horizontalTitleGap: 0,
|
||||
contentPadding: widget.portion.fromDiet ? const EdgeInsets.only(right: 24, left: 4) : null,
|
||||
contentPadding: widget.portion.fromDiet
|
||||
? const EdgeInsets.only(right: 24, left: 4)
|
||||
: null,
|
||||
title: Text(widget.portion.productName),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('${widget.portion.quantity} ${widget.portion.portion == 'gr' ? 'гр' : 'шт'}'),
|
||||
Text(
|
||||
'${widget.portion.quantity} ${widget.portion.portion == 'gr' ? 'гр' : 'шт'}',
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
_buildNutritionalInfo(),
|
||||
],
|
||||
),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
// ignore: use_full_hex_values_for_flutter_colors
|
||||
tileColor: const Color(0xfff6faff03).withValues(alpha: 0.01),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
Widget? trailing;
|
||||
if (state.isConsultantView && !isDiaryCommented) {
|
||||
if (state.isConsultantView && !state.isDiaryCommented) {
|
||||
trailing = GestureDetector(
|
||||
onTap: () {
|
||||
setState(() {
|
||||
@@ -180,13 +220,15 @@ class _ProductWidgetState extends State<ProductWidget> {
|
||||
},
|
||||
child: SvgPicture.asset('assets/icons/edit.svg'),
|
||||
);
|
||||
} else if (!isDiaryCommented && !widget.portion.fromDiet) {
|
||||
} else if (canEditPortion) {
|
||||
trailing = const Icon(Icons.more_vert_rounded);
|
||||
}
|
||||
return Column(
|
||||
children: [
|
||||
ListTile(
|
||||
onTap: canEditPortion ? () => updateProductInMeal(state, widget.portion) : null,
|
||||
onTap: canEditPortion
|
||||
? () => updateProductInMeal(state, widget.portion)
|
||||
: null,
|
||||
trailing: trailing,
|
||||
leading: widget.portion.fromDiet
|
||||
? Transform.scale(
|
||||
@@ -199,35 +241,55 @@ class _ProductWidgetState extends State<ProductWidget> {
|
||||
isEaten = value!;
|
||||
});
|
||||
if (isEaten) {
|
||||
diaryCubit.markEaten(diaryId: state.diary.id, mealId: state.selectedMeal, portionId: widget.portion.id);
|
||||
diaryCubit.markEaten(
|
||||
diaryId: state.diary.id,
|
||||
mealId: state.selectedMeal,
|
||||
portionId: widget.portion.id,
|
||||
);
|
||||
} else {
|
||||
diaryCubit.unmarkEaten(diaryId: state.diary.id, mealId: state.selectedMeal, portionId: widget.portion.id);
|
||||
diaryCubit.unmarkEaten(
|
||||
diaryId: state.diary.id,
|
||||
mealId: state.selectedMeal,
|
||||
portionId: widget.portion.id,
|
||||
);
|
||||
}
|
||||
}
|
||||
: null,
|
||||
activeColor: appTheme.blueA400,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(4)),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
side: const BorderSide(color: Color(0xFF809CB9)),
|
||||
),
|
||||
)
|
||||
: null,
|
||||
horizontalTitleGap: 0,
|
||||
contentPadding: widget.portion.fromDiet ? const EdgeInsets.only(right: 24, left: 4) : null,
|
||||
contentPadding: widget.portion.fromDiet
|
||||
? const EdgeInsets.only(right: 24, left: 4)
|
||||
: null,
|
||||
title: Text(widget.portion.productName),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('${widget.portion.quantity} ${widget.portion.portion == 'gr' ? 'гр' : 'шт'}'),
|
||||
Text(
|
||||
'${widget.portion.quantity} ${widget.portion.portion == 'gr' ? 'гр' : 'шт'}',
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
_buildNutritionalInfo(),
|
||||
],
|
||||
),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
tileColor: const Color(0xFFF6FAFF),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8.0),
|
||||
child: PortionCommentWidget(enabled: showCommentWidget, portionId: widget.portion.id, comment: portionComment?.comment),
|
||||
child: PortionCommentWidget(
|
||||
enabled: showCommentWidget,
|
||||
portionId: widget.portion.id,
|
||||
comment: portionComment?.comment,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -26,7 +26,8 @@ class _SatietyRatingWidgetState extends State<SatietyRatingWidget> {
|
||||
final meals = state.diary.meals;
|
||||
if (selectedIndex >= 0 && selectedIndex < meals.length) {
|
||||
if (isFirstFetch[selectedIndex]) {
|
||||
currentSelectedRating[selectedIndex] = meals[selectedIndex].satiety.toDouble() + 1;
|
||||
currentSelectedRating[selectedIndex] =
|
||||
meals[selectedIndex].satiety.toDouble() + 1;
|
||||
isFirstFetch[selectedIndex] = false;
|
||||
}
|
||||
}
|
||||
@@ -36,21 +37,32 @@ class _SatietyRatingWidgetState extends State<SatietyRatingWidget> {
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
state.isConsultantView ? 'Сытость пользователя после приема пищи' : 'Ваша сытость после приёма пищи',
|
||||
state.isConsultantView
|
||||
? 'Сытость пользователя после приема пищи'
|
||||
: 'Ваша сытость после приёма пищи',
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(fontSize: 16),
|
||||
),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
SvgPicture.asset('assets/icons/satiety_emoji_left.svg', height: 32, width: 32),
|
||||
SvgPicture.asset(
|
||||
'assets/icons/satiety_emoji_left.svg',
|
||||
height: 32,
|
||||
width: 32,
|
||||
),
|
||||
RatingBar.builder(
|
||||
ignoreGestures: state.isConsultantView || state.isDiaryFinished || state.isDiaryCommented,
|
||||
initialRating: (selectedIndex >= 0 && selectedIndex < state.diary.meals.length)
|
||||
? state.diary.meals[selectedIndex].satiety.toDouble() + 1
|
||||
ignoreGestures: !state.canEditDiary,
|
||||
initialRating:
|
||||
(selectedIndex >= 0 &&
|
||||
selectedIndex < state.diary.meals.length)
|
||||
? state.diary.meals[selectedIndex].satiety
|
||||
.toDouble() +
|
||||
1
|
||||
: 3,
|
||||
itemCount: 6,
|
||||
itemSize: MediaQuery.of(context).size.width / 6 - (124 / 6),
|
||||
itemSize:
|
||||
MediaQuery.of(context).size.width / 6 - (124 / 6),
|
||||
updateOnDrag: true,
|
||||
itemBuilder: (context, index) {
|
||||
switch (index) {
|
||||
@@ -58,82 +70,154 @@ class _SatietyRatingWidgetState extends State<SatietyRatingWidget> {
|
||||
return Container(
|
||||
width: MediaQuery.of(context).size.width / 6,
|
||||
height: 24,
|
||||
margin: currentSelectedRating[selectedIndex] == 1 ? const EdgeInsets.symmetric(horizontal: 2) : null,
|
||||
margin: currentSelectedRating[selectedIndex] == 1
|
||||
? const EdgeInsets.symmetric(horizontal: 2)
|
||||
: null,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFC82222),
|
||||
border: currentSelectedRating[selectedIndex] == 1
|
||||
? Border.all(width: 2, color: const Color(0xFFAA3333), strokeAlign: BorderSide.strokeAlignOutside)
|
||||
border:
|
||||
currentSelectedRating[selectedIndex] == 1
|
||||
? Border.all(
|
||||
width: 2,
|
||||
color: const Color(0xFFAA3333),
|
||||
strokeAlign:
|
||||
BorderSide.strokeAlignOutside,
|
||||
)
|
||||
: null,
|
||||
borderRadius: currentSelectedRating[selectedIndex] == 1
|
||||
? const BorderRadius.horizontal(left: Radius.circular(8), right: Radius.circular(2))
|
||||
: const BorderRadius.horizontal(left: Radius.circular(8)),
|
||||
borderRadius:
|
||||
currentSelectedRating[selectedIndex] == 1
|
||||
? const BorderRadius.horizontal(
|
||||
left: Radius.circular(8),
|
||||
right: Radius.circular(2),
|
||||
)
|
||||
: const BorderRadius.horizontal(
|
||||
left: Radius.circular(8),
|
||||
),
|
||||
),
|
||||
);
|
||||
case 1:
|
||||
return Container(
|
||||
width: MediaQuery.of(context).size.width / 6,
|
||||
height: 24,
|
||||
margin: currentSelectedRating[selectedIndex] == 2 ? const EdgeInsets.symmetric(horizontal: 2) : null,
|
||||
margin: currentSelectedRating[selectedIndex] == 2
|
||||
? const EdgeInsets.symmetric(horizontal: 2)
|
||||
: null,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFFF3E3E),
|
||||
border: currentSelectedRating[selectedIndex] == 2
|
||||
? Border.all(width: 2, color: const Color(0xFF550000), strokeAlign: BorderSide.strokeAlignOutside)
|
||||
border:
|
||||
currentSelectedRating[selectedIndex] == 2
|
||||
? Border.all(
|
||||
width: 2,
|
||||
color: const Color(0xFF550000),
|
||||
strokeAlign:
|
||||
BorderSide.strokeAlignOutside,
|
||||
)
|
||||
: null,
|
||||
borderRadius:
|
||||
currentSelectedRating[selectedIndex] == 2
|
||||
? BorderRadius.circular(2)
|
||||
: null,
|
||||
borderRadius: currentSelectedRating[selectedIndex] == 2 ? BorderRadius.circular(2) : null,
|
||||
),
|
||||
);
|
||||
case 2:
|
||||
return Container(
|
||||
width: MediaQuery.of(context).size.width / 6,
|
||||
height: 24,
|
||||
margin: currentSelectedRating[selectedIndex] == 3 ? const EdgeInsets.symmetric(horizontal: 2) : null,
|
||||
margin: currentSelectedRating[selectedIndex] == 3
|
||||
? const EdgeInsets.symmetric(horizontal: 2)
|
||||
: null,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFF8C926),
|
||||
border: currentSelectedRating[selectedIndex] == 3
|
||||
? Border.all(width: 2, color: const Color(0xFF8C5D00), strokeAlign: BorderSide.strokeAlignOutside)
|
||||
border:
|
||||
currentSelectedRating[selectedIndex] == 3
|
||||
? Border.all(
|
||||
width: 2,
|
||||
color: const Color(0xFF8C5D00),
|
||||
strokeAlign:
|
||||
BorderSide.strokeAlignOutside,
|
||||
)
|
||||
: null,
|
||||
borderRadius:
|
||||
currentSelectedRating[selectedIndex] == 3
|
||||
? BorderRadius.circular(2)
|
||||
: null,
|
||||
borderRadius: currentSelectedRating[selectedIndex] == 3 ? BorderRadius.circular(2) : null,
|
||||
),
|
||||
);
|
||||
case 3:
|
||||
return Container(
|
||||
width: MediaQuery.of(context).size.width / 6,
|
||||
height: 24,
|
||||
margin: currentSelectedRating[selectedIndex] == 4 ? const EdgeInsets.symmetric(horizontal: 2) : null,
|
||||
margin: currentSelectedRating[selectedIndex] == 4
|
||||
? const EdgeInsets.symmetric(horizontal: 2)
|
||||
: null,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFFFF3BF),
|
||||
border: currentSelectedRating[selectedIndex] == 4
|
||||
? Border.all(width: 2, color: const Color(0xFFB5911B), strokeAlign: BorderSide.strokeAlignOutside)
|
||||
border:
|
||||
currentSelectedRating[selectedIndex] == 4
|
||||
? Border.all(
|
||||
width: 2,
|
||||
color: const Color(0xFFB5911B),
|
||||
strokeAlign:
|
||||
BorderSide.strokeAlignOutside,
|
||||
)
|
||||
: null,
|
||||
borderRadius:
|
||||
currentSelectedRating[selectedIndex] == 4
|
||||
? BorderRadius.circular(2)
|
||||
: null,
|
||||
borderRadius: currentSelectedRating[selectedIndex] == 4 ? BorderRadius.circular(2) : null,
|
||||
),
|
||||
);
|
||||
case 4:
|
||||
return Container(
|
||||
width: MediaQuery.of(context).size.width / 6,
|
||||
height: 24,
|
||||
margin: currentSelectedRating[selectedIndex] == 5 ? const EdgeInsets.symmetric(horizontal: 2) : null,
|
||||
margin: currentSelectedRating[selectedIndex] == 5
|
||||
? const EdgeInsets.symmetric(horizontal: 2)
|
||||
: null,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF62E833),
|
||||
border: currentSelectedRating[selectedIndex] == 5
|
||||
? Border.all(width: 2, color: const Color(0xFF276918), strokeAlign: BorderSide.strokeAlignOutside)
|
||||
border:
|
||||
currentSelectedRating[selectedIndex] == 5
|
||||
? Border.all(
|
||||
width: 2,
|
||||
color: const Color(0xFF276918),
|
||||
strokeAlign:
|
||||
BorderSide.strokeAlignOutside,
|
||||
)
|
||||
: null,
|
||||
borderRadius:
|
||||
currentSelectedRating[selectedIndex] == 5
|
||||
? BorderRadius.circular(2)
|
||||
: null,
|
||||
borderRadius: currentSelectedRating[selectedIndex] == 5 ? BorderRadius.circular(2) : null,
|
||||
),
|
||||
);
|
||||
default:
|
||||
return Container(
|
||||
width: MediaQuery.of(context).size.width / 6,
|
||||
height: 24,
|
||||
margin: currentSelectedRating[selectedIndex] == 6 ? const EdgeInsets.symmetric(horizontal: 2) : null,
|
||||
margin: currentSelectedRating[selectedIndex] == 6
|
||||
? const EdgeInsets.symmetric(horizontal: 2)
|
||||
: null,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF4DCE20),
|
||||
border: currentSelectedRating[selectedIndex] == 6
|
||||
? Border.all(width: 2, color: const Color(0xFF184D12), strokeAlign: BorderSide.strokeAlignOutside)
|
||||
border:
|
||||
currentSelectedRating[selectedIndex] == 6
|
||||
? Border.all(
|
||||
width: 2,
|
||||
color: const Color(0xFF184D12),
|
||||
strokeAlign:
|
||||
BorderSide.strokeAlignOutside,
|
||||
)
|
||||
: null,
|
||||
borderRadius: currentSelectedRating[selectedIndex] == 6
|
||||
? const BorderRadius.horizontal(left: Radius.circular(2), right: Radius.circular(8))
|
||||
: const BorderRadius.horizontal(right: Radius.circular(8)),
|
||||
borderRadius:
|
||||
currentSelectedRating[selectedIndex] == 6
|
||||
? const BorderRadius.horizontal(
|
||||
left: Radius.circular(2),
|
||||
right: Radius.circular(8),
|
||||
)
|
||||
: const BorderRadius.horizontal(
|
||||
right: Radius.circular(8),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -143,12 +227,25 @@ class _SatietyRatingWidgetState extends State<SatietyRatingWidget> {
|
||||
setState(() {
|
||||
currentSelectedRating[selectedIndex] = value;
|
||||
});
|
||||
EasyDebounce.debounce('change-satiety', const Duration(milliseconds: 250), () {
|
||||
context.read<DiaryCubit>().fillSatiety(diaryId: state.diary.id, satiety: currentSelectedRating[selectedIndex].toInt() - 1);
|
||||
});
|
||||
EasyDebounce.debounce(
|
||||
'change-satiety',
|
||||
const Duration(milliseconds: 250),
|
||||
() {
|
||||
context.read<DiaryCubit>().fillSatiety(
|
||||
diaryId: state.diary.id,
|
||||
satiety:
|
||||
currentSelectedRating[selectedIndex].toInt() -
|
||||
1,
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
SvgPicture.asset('assets/icons/satiety_emoji_right.svg', height: 32, width: 32),
|
||||
SvgPicture.asset(
|
||||
'assets/icons/satiety_emoji_right.svg',
|
||||
height: 32,
|
||||
width: 32,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
|
||||
@@ -46,7 +46,8 @@ class _TimeOfMealPickerWidgetState extends State<TimeOfMealPickerWidget> {
|
||||
Navigator.of(context).pop();
|
||||
diaryCubit.fillTime(
|
||||
diaryId: diaryId,
|
||||
time: '${value.hour.toString().padLeft(2, '0')}:${value.minute.toString().padLeft(2, '0')}:00',
|
||||
time:
|
||||
'${value.hour.toString().padLeft(2, '0')}:${value.minute.toString().padLeft(2, '0')}:00',
|
||||
);
|
||||
},
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
@@ -69,7 +70,7 @@ class _TimeOfMealPickerWidgetState extends State<TimeOfMealPickerWidget> {
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 30)
|
||||
const SizedBox(height: 30),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -98,7 +99,9 @@ class _TimeOfMealPickerWidgetState extends State<TimeOfMealPickerWidget> {
|
||||
),
|
||||
),
|
||||
InkWell(
|
||||
onTap: state.isConsultantView || state.isDiaryFinished || state.isDiaryCommented ? null : () => showTimePicker(state.diary.id),
|
||||
onTap: state.canEditDiary
|
||||
? () => showTimePicker(state.diary.id)
|
||||
: null,
|
||||
borderRadius: BorderRadius.circular(100),
|
||||
child: Ink(
|
||||
height: 32,
|
||||
@@ -110,7 +113,16 @@ class _TimeOfMealPickerWidgetState extends State<TimeOfMealPickerWidget> {
|
||||
alignment: Alignment.center,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Text(
|
||||
state.diary.meals[state.selectedMeal - 1].time.substring(0, state.diary.meals[state.selectedMeal - 1].time.length - 3),
|
||||
state.diary.meals[state.selectedMeal - 1].time
|
||||
.substring(
|
||||
0,
|
||||
state
|
||||
.diary
|
||||
.meals[state.selectedMeal - 1]
|
||||
.time
|
||||
.length -
|
||||
3,
|
||||
),
|
||||
style: const TextStyle(
|
||||
color: Color(0xFF6D6F71),
|
||||
fontSize: 17,
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'package:eatvio_app/core/locator_service.dart';
|
||||
import 'package:eatvio_app/features/diets/presentation/widgets/info_bouble.dart';
|
||||
import 'package:eatvio_app/features/feed/models/diets_pagination_model.dart';
|
||||
import 'package:eatvio_app/core/theme/custom_text_style.dart';
|
||||
import 'package:eatvio_app/core/utils/url_utils.dart';
|
||||
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
||||
import 'package:eatvio_app/features/profile/models/profile_model.dart';
|
||||
import 'package:eatvio_app/features/user_details/presentation/user_details_screen.dart';
|
||||
@@ -11,7 +12,14 @@ import 'package:flutter_svg/svg.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
class AuthorCard extends StatelessWidget {
|
||||
const AuthorCard({super.key, required this.author, required this.title, this.backgroundColor, this.titleStyle, this.customerProfile});
|
||||
const AuthorCard({
|
||||
super.key,
|
||||
required this.author,
|
||||
required this.title,
|
||||
this.backgroundColor,
|
||||
this.titleStyle,
|
||||
this.customerProfile,
|
||||
});
|
||||
final Author author;
|
||||
final String title;
|
||||
final Color? backgroundColor;
|
||||
@@ -21,15 +29,28 @@ class AuthorCard extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
GoRouter.of(context).pushNamed('profileScreen', extra: customerProfile != null ? customerProfile!.slug : author.authorSlug);
|
||||
GoRouter.of(context).pushNamed(
|
||||
'profileScreen',
|
||||
extra: customerProfile != null
|
||||
? customerProfile!.slug
|
||||
: author.authorSlug,
|
||||
);
|
||||
},
|
||||
child: Container(
|
||||
width: MediaQuery.sizeOf(context).width,
|
||||
decoration: BoxDecoration(borderRadius: BorderRadius.circular(12), color: backgroundColor ?? appTheme.cardBackground),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
color: backgroundColor ?? appTheme.cardBackground,
|
||||
),
|
||||
child: Stack(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 16, top: 16, bottom: 24, right: 16),
|
||||
padding: const EdgeInsets.only(
|
||||
left: 16,
|
||||
top: 16,
|
||||
bottom: 24,
|
||||
right: 16,
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
ListTile(
|
||||
@@ -41,28 +62,43 @@ class AuthorCard extends StatelessWidget {
|
||||
shape: BoxShape.circle,
|
||||
image: DecorationImage(
|
||||
image: NetworkImage(
|
||||
customerProfile != null
|
||||
? customerProfile!.profilePhotoUrl.contains('https')
|
||||
? customerProfile!.profilePhotoUrl
|
||||
: sl<AppConfig>().baseUrl + customerProfile!.profilePhotoUrl
|
||||
: author.authorProfileIcon.contains('https')
|
||||
? author.authorProfileIcon
|
||||
: sl<AppConfig>().baseUrl + author.authorProfileIcon,
|
||||
joinBaseUrl(
|
||||
sl<AppConfig>().baseUrl,
|
||||
customerProfile != null
|
||||
? customerProfile!.profilePhotoUrl
|
||||
: author.authorProfileIcon,
|
||||
),
|
||||
),
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
),
|
||||
subtitle: Text(
|
||||
customerProfile != null ? customerProfile!.name : author.authorName,
|
||||
style: TextStyles.textLSemibold.copyWith(color: appTheme.black),
|
||||
customerProfile != null
|
||||
? customerProfile!.name
|
||||
: author.authorName,
|
||||
style: TextStyles.textLSemibold.copyWith(
|
||||
color: appTheme.black,
|
||||
),
|
||||
),
|
||||
title: Text(
|
||||
title,
|
||||
style:
|
||||
titleStyle ??
|
||||
TextStyles.textSSemibold.copyWith(
|
||||
color: appTheme.gray,
|
||||
),
|
||||
),
|
||||
title: Text(title, style: titleStyle ?? TextStyles.textSSemibold.copyWith(color: appTheme.gray)),
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
if (customerProfile == null) ...[
|
||||
Text('Консультант', style: TextStyles.textMMedium.copyWith(color: appTheme.black)),
|
||||
Text(
|
||||
'Консультант',
|
||||
style: TextStyles.textMMedium.copyWith(
|
||||
color: appTheme.black,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
|
||||
if (author.authorPlaceInRating != null)
|
||||
@@ -70,20 +106,35 @@ class AuthorCard extends StatelessWidget {
|
||||
radius: 12,
|
||||
backgroundColor: appTheme.cardBackground,
|
||||
children: [
|
||||
Text('${author.authorPlaceInRating}-й в рейтинге', style: TextStyles.textMMediumAuto.copyWith(color: appTheme.blueA400)),
|
||||
Text(
|
||||
'${author.authorPlaceInRating}-й в рейтинге',
|
||||
style: TextStyles.textMMediumAuto.copyWith(
|
||||
color: appTheme.blueA400,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
] else
|
||||
Expanded(
|
||||
child: GestureDetector(
|
||||
onTap: () {
|
||||
GoRouter.of(context).pushNamed(UserDetailsScreen.name, extra: customerProfile!.slug);
|
||||
GoRouter.of(context).pushNamed(
|
||||
UserDetailsScreen.name,
|
||||
extra: customerProfile!.slug,
|
||||
);
|
||||
},
|
||||
child: InfoBouble(
|
||||
radius: 12,
|
||||
backgroundColor: appTheme.cardBackground,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [Text('Анкета пользователя', style: TextStyles.textMMediumAuto.copyWith(color: appTheme.blueA400))],
|
||||
children: [
|
||||
Text(
|
||||
'Анкета пользователя',
|
||||
style: TextStyles.textMMediumAuto.copyWith(
|
||||
color: appTheme.blueA400,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -98,8 +149,18 @@ class AuthorCard extends StatelessWidget {
|
||||
child: Container(
|
||||
width: 28,
|
||||
height: 28,
|
||||
decoration: BoxDecoration(color: appTheme.whiteA700, borderRadius: BorderRadius.circular(8)),
|
||||
child: Center(child: SvgPicture.asset('assets/icons/arrow.svg', width: 12, height: 12, fit: BoxFit.none)),
|
||||
decoration: BoxDecoration(
|
||||
color: appTheme.whiteA700,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Center(
|
||||
child: SvgPicture.asset(
|
||||
'assets/icons/arrow.svg',
|
||||
width: 12,
|
||||
height: 12,
|
||||
fit: BoxFit.none,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'package:eatvio_app/core/comments/presentation/comments_actions_bottom_sh
|
||||
import 'package:eatvio_app/core/enums/complained_content.dart';
|
||||
import 'package:eatvio_app/core/locator_service.dart';
|
||||
import 'package:eatvio_app/core/utils/date_time_utils.dart';
|
||||
import 'package:eatvio_app/core/utils/url_utils.dart';
|
||||
import 'package:eatvio_app/core/utils/toasts.dart';
|
||||
import 'package:eatvio_app/features/bottom_navigation_bar/presentation/bloc/profile_avatar_cubit/profile_avatar_cubit.dart';
|
||||
import 'package:eatvio_app/features/bottom_navigation_bar/presentation/bloc/profile_avatar_cubit/profile_avatar_state.dart';
|
||||
@@ -54,13 +55,15 @@ class _CommentsWidgetState extends State<CommentsWidget> {
|
||||
@override
|
||||
void didUpdateWidget(CommentsWidget oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (widget.highlightedCommentId != null && widget.highlightedCommentId != oldWidget.highlightedCommentId) {
|
||||
if (widget.highlightedCommentId != null &&
|
||||
widget.highlightedCommentId != oldWidget.highlightedCommentId) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_scrollToHighlightedComment();
|
||||
});
|
||||
} else if (widget.comment != oldWidget.comment && widget.highlightedCommentId != null) {
|
||||
// Comments might have just loaded
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
} else if (widget.comment != oldWidget.comment &&
|
||||
widget.highlightedCommentId != null) {
|
||||
// Comments might have just loaded
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_scrollToHighlightedComment();
|
||||
});
|
||||
}
|
||||
@@ -84,9 +87,22 @@ class _CommentsWidgetState extends State<CommentsWidget> {
|
||||
children: [
|
||||
for (int i = 0; i < widget.comment.data.length; i++) ...[
|
||||
_comment(s, widget.comment.data[i]),
|
||||
if (widget.comment.data[i].childrenComments.isNotEmpty) const SizedBox(height: 8),
|
||||
for (int j = 0; j < widget.comment.data[i].childrenComments.length; j++)
|
||||
Padding(padding: const EdgeInsets.only(left: 40), child: _comment(s, widget.comment.data[i].childrenComments[j], 16, true)),
|
||||
if (widget.comment.data[i].childrenComments.isNotEmpty)
|
||||
const SizedBox(height: 8),
|
||||
for (
|
||||
int j = 0;
|
||||
j < widget.comment.data[i].childrenComments.length;
|
||||
j++
|
||||
)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 40),
|
||||
child: _comment(
|
||||
s,
|
||||
widget.comment.data[i].childrenComments[j],
|
||||
16,
|
||||
true,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
const SizedBox(height: 48),
|
||||
@@ -94,9 +110,18 @@ class _CommentsWidgetState extends State<CommentsWidget> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _comment(S s, Comment comment, [double radius = 18, bool isChild = false]) {
|
||||
Widget _comment(
|
||||
S s,
|
||||
Comment comment, [
|
||||
double radius = 18,
|
||||
bool isChild = false,
|
||||
]) {
|
||||
final key = commentKeys.putIfAbsent(comment.id, () => GlobalKey());
|
||||
final isMe = (sl<ProfileAvatarCubit>().state as ProfileAvatarStateLoaded).profile.slug == comment.authorSlug;
|
||||
final isMe =
|
||||
(sl<ProfileAvatarCubit>().state as ProfileAvatarStateLoaded)
|
||||
.profile
|
||||
.slug ==
|
||||
comment.authorSlug;
|
||||
return GestureDetector(
|
||||
key: key,
|
||||
onTap: () {
|
||||
@@ -113,7 +138,10 @@ class _CommentsWidgetState extends State<CommentsWidget> {
|
||||
showInfoToast(s.text_was_copied);
|
||||
},
|
||||
edit: (String text) {
|
||||
context.read<CommentsCubit>().updateComment(id: comment.id, body: text);
|
||||
context.read<CommentsCubit>().updateComment(
|
||||
id: comment.id,
|
||||
body: text,
|
||||
);
|
||||
},
|
||||
delete: () {
|
||||
context.read<CommentsCubit>().deleteComment(id: comment.id);
|
||||
@@ -122,7 +150,10 @@ class _CommentsWidgetState extends State<CommentsWidget> {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
builder: (context) => ComplainingBottomSheet(complainedContentType: ComplainedContent.comment, id: comment.id),
|
||||
builder: (context) => ComplainingBottomSheet(
|
||||
complainedContentType: ComplainedContent.comment,
|
||||
id: comment.id,
|
||||
),
|
||||
);
|
||||
},
|
||||
isMyComment: isMe,
|
||||
@@ -131,7 +162,11 @@ class _CommentsWidgetState extends State<CommentsWidget> {
|
||||
child: Container(
|
||||
width: MediaQuery.of(context).size.width,
|
||||
decoration: BoxDecoration(
|
||||
color: (widget.replyId == comment.id || widget.highlightedCommentId == comment.id) ? appTheme.blueA400.withValues(alpha: 0.1) : Colors.white,
|
||||
color:
|
||||
(widget.replyId == comment.id ||
|
||||
widget.highlightedCommentId == comment.id)
|
||||
? appTheme.blueA400.withValues(alpha: 0.1)
|
||||
: Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Padding(
|
||||
@@ -141,14 +176,20 @@ class _CommentsWidgetState extends State<CommentsWidget> {
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
GoRouter.of(context).pushNamed(
|
||||
'profileScreen',
|
||||
extra: comment.authorSlug,
|
||||
);
|
||||
GoRouter.of(
|
||||
context,
|
||||
).pushNamed('profileScreen', extra: comment.authorSlug);
|
||||
},
|
||||
child: CircleAvatar(
|
||||
foregroundImage: NetworkImage(comment.authorProfileIcon.contains('https') ? comment.authorProfileIcon : sl<AppConfig>().baseUrl + comment.authorProfileIcon),
|
||||
backgroundImage: const AssetImage('assets/images/no_avatar.png'),
|
||||
foregroundImage: NetworkImage(
|
||||
joinBaseUrl(
|
||||
sl<AppConfig>().baseUrl,
|
||||
comment.authorProfileIcon,
|
||||
),
|
||||
),
|
||||
backgroundImage: const AssetImage(
|
||||
'assets/images/no_avatar.png',
|
||||
),
|
||||
radius: radius,
|
||||
),
|
||||
),
|
||||
@@ -159,14 +200,26 @@ class _CommentsWidgetState extends State<CommentsWidget> {
|
||||
children: [
|
||||
Text(
|
||||
comment.authorName,
|
||||
style: TextStyle(height: 1, color: appTheme.gray, fontSize: 14, fontWeight: FontWeight.w500),
|
||||
style: TextStyle(
|
||||
height: 1,
|
||||
color: appTheme.gray,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
SizedBox(
|
||||
width: isChild ? MediaQuery.of(context).size.width * 0.72 : MediaQuery.of(context).size.width * 0.8,
|
||||
width: isChild
|
||||
? MediaQuery.of(context).size.width * 0.72
|
||||
: MediaQuery.of(context).size.width * 0.8,
|
||||
child: Text(
|
||||
comment.body,
|
||||
style: TextStyle(height: 1, color: appTheme.black, fontSize: 16, fontWeight: FontWeight.w500),
|
||||
style: TextStyle(
|
||||
height: 1,
|
||||
color: appTheme.black,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
@@ -174,17 +227,29 @@ class _CommentsWidgetState extends State<CommentsWidget> {
|
||||
children: [
|
||||
Text(
|
||||
comment.createdAt.format('dd MMMM', 'ru'),
|
||||
style: TextStyle(height: 1, color: appTheme.gray, fontSize: 14, fontWeight: FontWeight.w500),
|
||||
style: TextStyle(
|
||||
height: 1,
|
||||
color: appTheme.gray,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
if (!isChild)
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
context.read<CommentsCubit>().setReplyId(comment.id);
|
||||
context.read<CommentsCubit>().setReplyId(
|
||||
comment.id,
|
||||
);
|
||||
},
|
||||
child: Text(
|
||||
s.answer,
|
||||
style: TextStyle(height: 1, color: appTheme.gray, fontSize: 14, fontWeight: FontWeight.w600),
|
||||
style: TextStyle(
|
||||
height: 1,
|
||||
color: appTheme.gray,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -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/utils/url_utils.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';
|
||||
@@ -18,15 +19,15 @@ class ConsultantWidget extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
return InkWell(
|
||||
onTap: () {
|
||||
GoRouter.of(context).pushNamed(
|
||||
'profileScreen',
|
||||
extra: consultant.slug,
|
||||
);
|
||||
GoRouter.of(context).pushNamed('profileScreen', extra: consultant.slug);
|
||||
},
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
child: Ink(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(borderRadius: BorderRadius.circular(20), color: const Color(0xFFF6F6F6)),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
color: const Color(0xFFF6F6F6),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
@@ -37,33 +38,54 @@ class ConsultantWidget extends StatelessWidget {
|
||||
Row(
|
||||
children: [
|
||||
Ink(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 8),
|
||||
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(8)),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical: 4,
|
||||
horizontal: 8,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
'${consultant.ratingPlace}',
|
||||
style: TextStyle(fontSize: 20, color: appTheme.blueA400, fontWeight: FontWeight.w600),
|
||||
style: TextStyle(
|
||||
fontSize: 20,
|
||||
color: appTheme.blueA400,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
if (consultant.photo.contains('https'))
|
||||
CircleAvatar(foregroundImage: NetworkImage(consultant.photo), backgroundImage: const AssetImage('assets/images/no_avatar.png'))
|
||||
else
|
||||
CircleAvatar(
|
||||
foregroundImage: NetworkImage(sl<AppConfig>().baseUrl + consultant.photo),
|
||||
backgroundImage: const AssetImage('assets/images/no_avatar.png'),
|
||||
CircleAvatar(
|
||||
foregroundImage: NetworkImage(
|
||||
joinBaseUrl(sl<AppConfig>().baseUrl, consultant.photo),
|
||||
),
|
||||
backgroundImage: const AssetImage(
|
||||
'assets/images/no_avatar.png',
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
consultant.name,
|
||||
style: const TextStyle(height: 1, fontWeight: FontWeight.w500, fontSize: 15, color: Color(0xFF323F4B)),
|
||||
style: const TextStyle(
|
||||
height: 1,
|
||||
fontWeight: FontWeight.w500,
|
||||
fontSize: 15,
|
||||
color: Color(0xFF323F4B),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Рейтинг: ${consultant.totalScores}',
|
||||
style: TextStyle(color: appTheme.blueA400, height: 1, fontWeight: FontWeight.w500, fontSize: 12),
|
||||
style: TextStyle(
|
||||
color: appTheme.blueA400,
|
||||
height: 1,
|
||||
fontWeight: FontWeight.w500,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -71,7 +93,10 @@ class ConsultantWidget extends StatelessWidget {
|
||||
),
|
||||
Ink(
|
||||
padding: const EdgeInsets.all(6),
|
||||
decoration: BoxDecoration(borderRadius: BorderRadius.circular(4), color: Colors.white),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
color: Colors.white,
|
||||
),
|
||||
child: SvgPicture.asset('assets/icons/go_to_arrow.svg'),
|
||||
),
|
||||
],
|
||||
@@ -93,9 +118,21 @@ class ConsultantWidget extends StatelessWidget {
|
||||
children: [
|
||||
for (int i = 0; i < badges.length; i++) ...[
|
||||
badges[i].image.contains('svg')
|
||||
? 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(),
|
||||
? 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(),
|
||||
],
|
||||
],
|
||||
),
|
||||
|
||||
@@ -11,6 +11,7 @@ import 'package:eatvio_app/core/widgets/markdown_html_widget.dart';
|
||||
import 'package:eatvio_app/features/diets/presentation/widgets/info_bouble.dart';
|
||||
import 'package:eatvio_app/features/feed/models/plan_pagination_model.dart';
|
||||
import 'package:eatvio_app/core/theme/custom_text_style.dart';
|
||||
import 'package:eatvio_app/core/utils/url_utils.dart';
|
||||
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
||||
import 'package:eatvio_app/features/feed/presentation/bloc/plans_cubit/plans_cubit.dart';
|
||||
import 'package:eatvio_app/generated/l10n.dart';
|
||||
@@ -37,7 +38,14 @@ class _PlanWidgetState extends State<PlanWidget> {
|
||||
Widget build(BuildContext context) {
|
||||
final s = S.of(context);
|
||||
return InkWell(
|
||||
onTap: () => GoRouter.of(context).pushNamed('aboutPlanScreen', extra: {'slug': widget.plan.slug, 'index': widget.index, 'navigateToComments': false}),
|
||||
onTap: () => GoRouter.of(context).pushNamed(
|
||||
'aboutPlanScreen',
|
||||
extra: {
|
||||
'slug': widget.plan.slug,
|
||||
'index': widget.index,
|
||||
'navigateToComments': false,
|
||||
},
|
||||
),
|
||||
child: Material(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
color: Colors.white,
|
||||
@@ -48,9 +56,12 @@ class _PlanWidgetState extends State<PlanWidget> {
|
||||
Stack(
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(12)),
|
||||
borderRadius: const BorderRadius.vertical(
|
||||
top: Radius.circular(12),
|
||||
),
|
||||
child: CachedNetworkImage(
|
||||
imageUrl: '${sl<AppConfig>().baseUrl}/storage/${widget.plan.image}',
|
||||
imageUrl:
|
||||
'${sl<AppConfig>().baseUrl}/storage/${widget.plan.image}',
|
||||
fit: BoxFit.cover,
|
||||
width: MediaQuery.of(context).size.width,
|
||||
height: 426,
|
||||
@@ -61,14 +72,22 @@ class _PlanWidgetState extends State<PlanWidget> {
|
||||
child: BookmarkButton(
|
||||
onTap: () async {
|
||||
if (widget.plan.isFavorite) {
|
||||
await context.read<PlansCubit>().removePlanFromFavorite(widget.plan.slug, widget.index);
|
||||
await context.read<PlansCubit>().removePlanFromFavorite(
|
||||
widget.plan.slug,
|
||||
widget.index,
|
||||
);
|
||||
} else {
|
||||
await context.read<PlansCubit>().addPlanToFavorite(widget.plan.slug, widget.index);
|
||||
await context.read<PlansCubit>().addPlanToFavorite(
|
||||
widget.plan.slug,
|
||||
widget.index,
|
||||
);
|
||||
}
|
||||
},
|
||||
color: appTheme.whiteA700,
|
||||
isFavorite: widget.plan.isFavorite,
|
||||
borderRadius: const BorderRadius.vertical(bottom: Radius.circular(12)),
|
||||
borderRadius: const BorderRadius.vertical(
|
||||
bottom: Radius.circular(12),
|
||||
),
|
||||
width: 40,
|
||||
height: 44,
|
||||
size: 19,
|
||||
@@ -89,6 +108,7 @@ class _PlanWidgetState extends State<PlanWidget> {
|
||||
path: 'plan/${widget.plan.slug}',
|
||||
id: widget.plan.id,
|
||||
slug: widget.plan.slug,
|
||||
plan: widget.plan,
|
||||
complainedContentType: ComplainedContent.plan,
|
||||
userSlug: widget.plan.author.authorSlug,
|
||||
),
|
||||
@@ -99,7 +119,12 @@ class _PlanWidgetState extends State<PlanWidget> {
|
||||
bottom: 16,
|
||||
child: SizedBox(
|
||||
width: MediaQuery.sizeOf(context).width,
|
||||
child: InfoRow(plan: widget.plan, itemsBackgroundColor: appTheme.whiteA700, disableDescriptionOfTypes: true, type: ProductType.plan),
|
||||
child: InfoRow(
|
||||
plan: widget.plan,
|
||||
itemsBackgroundColor: appTheme.whiteA700,
|
||||
disableDescriptionOfTypes: true,
|
||||
type: ProductType.plan,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -110,14 +135,22 @@ class _PlanWidgetState extends State<PlanWidget> {
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Text(widget.plan.name, style: TextStyles.textMSemibold.copyWith(color: appTheme.black, fontSize: 18)),
|
||||
child: Text(
|
||||
widget.plan.name,
|
||||
style: TextStyles.textMSemibold.copyWith(
|
||||
color: appTheme.black,
|
||||
fontSize: 18,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (widget.plan.details.isNotEmpty) ...[
|
||||
const SizedBox(height: 12),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: MarkdownHtmlWidget(
|
||||
markdownText: widget.plan.details.length > 200 ? '${widget.plan.details.substring(0, 200)}...' : widget.plan.details,
|
||||
markdownText: widget.plan.details.length > 200
|
||||
? '${widget.plan.details.substring(0, 200)}...'
|
||||
: widget.plan.details,
|
||||
textColor: appTheme.black,
|
||||
),
|
||||
),
|
||||
@@ -126,7 +159,12 @@ class _PlanWidgetState extends State<PlanWidget> {
|
||||
padding: EdgeInsets.only(left: 16, top: 8),
|
||||
child: Text(
|
||||
'Показать ещё',
|
||||
style: TextStyle(height: 1, color: Color(0xFF8B8B8B), fontSize: 14, fontWeight: FontWeight.w600),
|
||||
style: TextStyle(
|
||||
height: 1,
|
||||
color: Color(0xFF8B8B8B),
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -135,7 +173,10 @@ class _PlanWidgetState extends State<PlanWidget> {
|
||||
const SizedBox(height: 16),
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
GoRouter.of(context).pushNamed('profileScreen', extra: widget.plan.author.authorSlug);
|
||||
GoRouter.of(context).pushNamed(
|
||||
'profileScreen',
|
||||
extra: widget.plan.author.authorSlug,
|
||||
);
|
||||
},
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(left: 16.0),
|
||||
@@ -146,7 +187,15 @@ class _PlanWidgetState extends State<PlanWidget> {
|
||||
height: 46,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
image: DecorationImage(image: NetworkImage(sl<AppConfig>().baseUrl + widget.plan.author.authorProfileIcon), fit: BoxFit.cover),
|
||||
image: DecorationImage(
|
||||
image: NetworkImage(
|
||||
joinBaseUrl(
|
||||
sl<AppConfig>().baseUrl,
|
||||
widget.plan.author.authorProfileIcon,
|
||||
),
|
||||
),
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
),
|
||||
subtitle: Row(
|
||||
@@ -154,22 +203,37 @@ class _PlanWidgetState extends State<PlanWidget> {
|
||||
InfoBouble(
|
||||
radius: 12,
|
||||
backgroundColor: appTheme.gray50,
|
||||
padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 10),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical: 4,
|
||||
horizontal: 10,
|
||||
),
|
||||
children: [
|
||||
Text('${widget.plan.author.authorPlaceInRating}-й в рейтинге', style: TextStyles.textXSSemibold.copyWith(color: appTheme.blueA400)),
|
||||
Text(
|
||||
'${widget.plan.author.authorPlaceInRating}-й в рейтинге',
|
||||
style: TextStyles.textXSSemibold.copyWith(
|
||||
color: appTheme.blueA400,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const Spacer(),
|
||||
Text(
|
||||
'${widget.plan.packetsCount == 1 ? widget.plan.packet1Options?.price : '${s.from} ${widget.plan.minPriceOfOptions.toStringAsFixed(0)}'}₽',
|
||||
style: TextStyles.headingXLSemibold.copyWith(color: appTheme.greenA700),
|
||||
style: TextStyles.headingXLSemibold.copyWith(
|
||||
color: appTheme.greenA700,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
],
|
||||
),
|
||||
title: Padding(
|
||||
padding: const EdgeInsets.only(bottom: 4.0),
|
||||
child: Text(widget.plan.author.authorName, style: TextStyles.textSSemibold.copyWith(color: appTheme.black)),
|
||||
child: Text(
|
||||
widget.plan.author.authorName,
|
||||
style: TextStyles.textSSemibold.copyWith(
|
||||
color: appTheme.black,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -180,12 +244,21 @@ class _PlanWidgetState extends State<PlanWidget> {
|
||||
child: Row(
|
||||
children: [
|
||||
FeedIconButton(
|
||||
icon: widget.plan.wasLikedByMe ? Icons.favorite_rounded : Icons.favorite_border_rounded,
|
||||
icon: widget.plan.wasLikedByMe
|
||||
? Icons.favorite_rounded
|
||||
: Icons.favorite_border_rounded,
|
||||
text: widget.plan.totalLikes.toString(),
|
||||
iconColor: widget.plan.wasLikedByMe ? appTheme.blueA400 : null,
|
||||
backgroundColor: widget.plan.wasLikedByMe ? appTheme.transparentBlue : null,
|
||||
iconColor: widget.plan.wasLikedByMe
|
||||
? appTheme.blueA400
|
||||
: null,
|
||||
backgroundColor: widget.plan.wasLikedByMe
|
||||
? appTheme.transparentBlue
|
||||
: null,
|
||||
onTap: () async {
|
||||
await context.read<PlansCubit>().likePlan(widget.plan.slug, widget.index);
|
||||
await context.read<PlansCubit>().likePlan(
|
||||
widget.plan.slug,
|
||||
widget.index,
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
@@ -193,9 +266,14 @@ class _PlanWidgetState extends State<PlanWidget> {
|
||||
icon: Icons.chat_bubble_outline,
|
||||
text: widget.plan.totalComments.toString(),
|
||||
onTap: () {
|
||||
return GoRouter.of(
|
||||
context,
|
||||
).pushNamed('aboutPlanScreen', extra: {'slug': widget.plan.slug, 'index': widget.index, 'navigateToComments': true});
|
||||
return GoRouter.of(context).pushNamed(
|
||||
'aboutPlanScreen',
|
||||
extra: {
|
||||
'slug': widget.plan.slug,
|
||||
'index': widget.index,
|
||||
'navigateToComments': true,
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
@@ -205,8 +283,11 @@ class _PlanWidgetState extends State<PlanWidget> {
|
||||
final box = context.findRenderObject() as RenderBox?;
|
||||
final result = await SharePlus.instance.share(
|
||||
ShareParams(
|
||||
uri: Uri.parse('${sl<AppConfig>().baseUrl}/ru/diet/${widget.plan.id}'),
|
||||
sharePositionOrigin: box!.localToGlobal(Offset.zero) & box.size,
|
||||
uri: Uri.parse(
|
||||
'${sl<AppConfig>().baseUrl}/ru/diet/${widget.plan.id}',
|
||||
),
|
||||
sharePositionOrigin:
|
||||
box!.localToGlobal(Offset.zero) & box.size,
|
||||
),
|
||||
);
|
||||
switch (result.status) {
|
||||
|
||||
@@ -2,10 +2,12 @@ import 'dart:developer';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:eatvio_app/core/error_reporting/report_from.dart';
|
||||
import 'package:eatvio_app/features/menu/presentation/bloc/notification_settings_cubit/notification_settings_state.dart';
|
||||
import 'package:eatvio_app/features/menu/presentation/notification_settings_user_message.dart';
|
||||
import 'package:eatvio_app/features/menu/repositories/notification_settings_repository.dart';
|
||||
|
||||
class NotificationSettingsCubit extends Cubit<NotificationSettingsState> {
|
||||
final NotificationSettingsRepository _notificationSettingsRepository;
|
||||
int _alertSeq = 0;
|
||||
|
||||
NotificationSettingsCubit({required NotificationSettingsRepository notificationSettingsRepository})
|
||||
: _notificationSettingsRepository = notificationSettingsRepository,
|
||||
@@ -30,6 +32,17 @@ class NotificationSettingsCubit extends Cubit<NotificationSettingsState> {
|
||||
} catch (e, stackTrace) {
|
||||
await reportFrom(e, stackTrace, this);
|
||||
log('Error updating notification settings: $e', stackTrace: stackTrace);
|
||||
final current = state;
|
||||
if (current is NotificationSettingsStateLoaded) {
|
||||
emit(
|
||||
NotificationSettingsStateLoaded(
|
||||
notificationSettings: current.notificationSettings,
|
||||
alertMessage: notificationSettingsUpdateAlertMessage(e),
|
||||
alertId: ++_alertSeq,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
emit(NotificationSettingsStateError(errorMessage: e.toString()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,11 +14,17 @@ class NotificationSettingsStateLoading extends NotificationSettingsState {
|
||||
|
||||
class NotificationSettingsStateLoaded extends NotificationSettingsState {
|
||||
final NotificationSettingsModel notificationSettings;
|
||||
final String? alertMessage;
|
||||
final int? alertId;
|
||||
|
||||
const NotificationSettingsStateLoaded({required this.notificationSettings});
|
||||
const NotificationSettingsStateLoaded({
|
||||
required this.notificationSettings,
|
||||
this.alertMessage,
|
||||
this.alertId,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [notificationSettings];
|
||||
List<Object?> get props => [notificationSettings, alertMessage, alertId];
|
||||
}
|
||||
|
||||
class NotificationSettingsStateError extends NotificationSettingsState {
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
const notificationSettingsSaveFailedMessage =
|
||||
'Не удалось сохранить настройки уведомлений';
|
||||
|
||||
|
||||
String notificationSettingsUpdateAlertMessage(Object error) {
|
||||
final validationMessage = _validationMessage(error);
|
||||
if (validationMessage != null) return validationMessage;
|
||||
return notificationSettingsSaveFailedMessage;
|
||||
}
|
||||
|
||||
String? _validationMessage(Object error) {
|
||||
if (error is! DioException || error.response?.statusCode != 422) return null;
|
||||
|
||||
final data = error.response?.data;
|
||||
if (data is! Map) return null;
|
||||
|
||||
final errors = data['errors'];
|
||||
if (errors is Map) {
|
||||
for (final value in errors.values) {
|
||||
final text = _firstErrorText(value);
|
||||
if (text != null) return text;
|
||||
}
|
||||
}
|
||||
|
||||
final message = data['message'];
|
||||
if (message is String && message.trim().isNotEmpty) return message.trim();
|
||||
return null;
|
||||
}
|
||||
|
||||
String? _firstErrorText(Object? value) {
|
||||
if (value is List) {
|
||||
for (final item in value) {
|
||||
final text = item.toString().trim();
|
||||
if (text.isNotEmpty) return text;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (value is String && value.trim().isNotEmpty) return value.trim();
|
||||
return null;
|
||||
}
|
||||
@@ -27,7 +27,37 @@ class NotificationsSettingsContent extends StatelessWidget {
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: Text(s.notifications)),
|
||||
body: BlocBuilder<NotificationSettingsCubit, NotificationSettingsState>(
|
||||
body: BlocConsumer<NotificationSettingsCubit, NotificationSettingsState>(
|
||||
listenWhen: (previous, current) {
|
||||
if (current is! NotificationSettingsStateLoaded || current.alertId == null) {
|
||||
return false;
|
||||
}
|
||||
final previousId = previous is NotificationSettingsStateLoaded ? previous.alertId : null;
|
||||
return current.alertId != previousId;
|
||||
},
|
||||
listener: (context, state) {
|
||||
if (state is! NotificationSettingsStateLoaded) {
|
||||
return;
|
||||
}
|
||||
final message = state.alertMessage;
|
||||
if (message == null || message.isEmpty) {
|
||||
return;
|
||||
}
|
||||
showCupertinoDialog<void>(
|
||||
context: context,
|
||||
builder: (dialogContext) {
|
||||
return CupertinoAlertDialog(
|
||||
content: Text(message),
|
||||
actions: [
|
||||
CupertinoDialogAction(
|
||||
onPressed: () => Navigator.of(dialogContext).pop(),
|
||||
child: const Text('OK'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
builder: (context, state) {
|
||||
if (state is NotificationSettingsStateLoading) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:eatvio_app/core/app_config.dart';
|
||||
import 'package:eatvio_app/core/locator_service.dart';
|
||||
import 'package:eatvio_app/core/utils/url_utils.dart';
|
||||
import 'package:eatvio_app/features/menu/presentation/blocked_user_cubit/blocked_users_cubit.dart';
|
||||
import 'package:eatvio_app/features/profile/models/profile_model.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
@@ -18,16 +19,20 @@ class _BlockedUserWidgetState extends State<BlockedUserWidget> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return InkWell(
|
||||
onTap: () => GoRouter.of(context).pushNamed(
|
||||
'profileScreen',
|
||||
extra: widget.profile.slug,
|
||||
),
|
||||
onTap: () => GoRouter.of(
|
||||
context,
|
||||
).pushNamed('profileScreen', extra: widget.profile.slug),
|
||||
child: Ink(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
|
||||
child: Row(
|
||||
children: [
|
||||
CircleAvatar(
|
||||
foregroundImage: NetworkImage(sl<AppConfig>().baseUrl + widget.profile.profilePhotoUrl),
|
||||
foregroundImage: NetworkImage(
|
||||
joinBaseUrl(
|
||||
sl<AppConfig>().baseUrl,
|
||||
widget.profile.profilePhotoUrl,
|
||||
),
|
||||
),
|
||||
backgroundImage: const AssetImage('assets/images/no_avatar.png'),
|
||||
radius: 28,
|
||||
),
|
||||
@@ -50,11 +55,16 @@ class _BlockedUserWidgetState extends State<BlockedUserWidget> {
|
||||
const Spacer(),
|
||||
InkWell(
|
||||
onTap: () {
|
||||
context.read<BlockedUsersCubit>().unblockUser(widget.profile.slug);
|
||||
context.read<BlockedUsersCubit>().unblockUser(
|
||||
widget.profile.slug,
|
||||
);
|
||||
},
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
child: Ink(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 8,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFF1F2F6),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:eatvio_app/core/app_config.dart';
|
||||
import 'package:eatvio_app/core/utils/url_utils.dart';
|
||||
import 'package:eatvio_app/core/app_router.dart';
|
||||
import 'package:eatvio_app/core/locator_service.dart';
|
||||
import 'package:eatvio_app/core/theme/custom_text_style.dart';
|
||||
@@ -14,7 +15,11 @@ import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
class ConsultationWidget extends StatelessWidget {
|
||||
const ConsultationWidget({super.key, required this.consultationModel, required this.dietName});
|
||||
const ConsultationWidget({
|
||||
super.key,
|
||||
required this.consultationModel,
|
||||
required this.dietName,
|
||||
});
|
||||
final ConsultationModel consultationModel;
|
||||
final String dietName;
|
||||
@override
|
||||
@@ -33,7 +38,12 @@ class ConsultationWidget extends StatelessWidget {
|
||||
children: [
|
||||
CircleAvatar(
|
||||
radius: 30,
|
||||
backgroundImage: NetworkImage(sl<AppConfig>().baseUrl + consultationModel.userProfilePhotoUrl),
|
||||
backgroundImage: NetworkImage(
|
||||
joinBaseUrl(
|
||||
sl<AppConfig>().baseUrl,
|
||||
consultationModel.userProfilePhotoUrl,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Column(
|
||||
@@ -43,18 +53,24 @@ class ConsultationWidget extends StatelessWidget {
|
||||
children: [
|
||||
Text(
|
||||
consultationModel.userName,
|
||||
style: TextStyles.textLSemibold.copyWith(color: appTheme.black),
|
||||
style: TextStyles.textLSemibold.copyWith(
|
||||
color: appTheme.black,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
consultationModel.planTitle,
|
||||
style: TextStyles.textMMedium.copyWith(color: const Color(0xFF88949F)),
|
||||
style: TextStyles.textMMedium.copyWith(
|
||||
color: const Color(0xFF88949F),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'Старт: ${consultationModel.userName}',
|
||||
style: TextStyles.textMMedium.copyWith(color: const Color(0xFF88949F)),
|
||||
style: TextStyles.textMMedium.copyWith(
|
||||
color: const Color(0xFF88949F),
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
),
|
||||
],
|
||||
)
|
||||
else
|
||||
@@ -62,7 +78,12 @@ class ConsultationWidget extends StatelessWidget {
|
||||
children: [
|
||||
CircleAvatar(
|
||||
radius: 30,
|
||||
backgroundImage: NetworkImage(sl<AppConfig>().baseUrl + consultationModel.userProfilePhotoUrl),
|
||||
backgroundImage: NetworkImage(
|
||||
joinBaseUrl(
|
||||
sl<AppConfig>().baseUrl,
|
||||
consultationModel.userProfilePhotoUrl,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Column(
|
||||
@@ -72,25 +93,32 @@ class ConsultationWidget extends StatelessWidget {
|
||||
children: [
|
||||
Text(
|
||||
consultationModel.userName,
|
||||
style: TextStyles.textLSemibold.copyWith(color: appTheme.black),
|
||||
style: TextStyles.textLSemibold.copyWith(
|
||||
color: appTheme.black,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
consultationModel.planTitle,
|
||||
style: TextStyles.textMMedium.copyWith(color: const Color(0xFF88949F)),
|
||||
style: TextStyles.textMMedium.copyWith(
|
||||
color: const Color(0xFF88949F),
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(
|
||||
height: 12,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
if (consultationModel.personalDietId != null)
|
||||
CustomOutlinedButton(
|
||||
text: s.changeDiet,
|
||||
height: 52,
|
||||
buttonTextStyle: TextStyles.textMSemibold.copyWith(color: appTheme.gray),
|
||||
buttonStyle: ButtonStyle(side: const WidgetStatePropertyAll(BorderSide.none), backgroundColor: WidgetStatePropertyAll(appTheme.dividerColor)),
|
||||
buttonTextStyle: TextStyles.textMSemibold.copyWith(
|
||||
color: appTheme.gray,
|
||||
),
|
||||
buttonStyle: ButtonStyle(
|
||||
side: const WidgetStatePropertyAll(BorderSide.none),
|
||||
backgroundColor: WidgetStatePropertyAll(appTheme.dividerColor),
|
||||
),
|
||||
onPressed: () async {
|
||||
showBottomSheetWithBlur(
|
||||
userRootNavigator: true,
|
||||
@@ -100,7 +128,11 @@ class ConsultationWidget extends StatelessWidget {
|
||||
userName: consultationModel.userName,
|
||||
dietName: dietName,
|
||||
onTap: () async {
|
||||
final result = await context.read<PersonalDietSuggestionCubit>().attachDietToConsultation(consultationId: consultationModel.id);
|
||||
final result = await context
|
||||
.read<PersonalDietSuggestionCubit>()
|
||||
.attachDietToConsultation(
|
||||
consultationId: consultationModel.id,
|
||||
);
|
||||
AppRouter.navigatorKey.currentContext?.pop();
|
||||
if (result) {
|
||||
if (!context.mounted) return;
|
||||
@@ -134,8 +166,13 @@ class ConsultationWidget extends StatelessWidget {
|
||||
CustomOutlinedButton(
|
||||
text: s.suggestDiet,
|
||||
height: 52,
|
||||
buttonTextStyle: TextStyles.textMSemibold.copyWith(color: appTheme.whiteA700),
|
||||
buttonStyle: ButtonStyle(side: const WidgetStatePropertyAll(BorderSide.none), backgroundColor: WidgetStatePropertyAll(appTheme.blueA400)),
|
||||
buttonTextStyle: TextStyles.textMSemibold.copyWith(
|
||||
color: appTheme.whiteA700,
|
||||
),
|
||||
buttonStyle: ButtonStyle(
|
||||
side: const WidgetStatePropertyAll(BorderSide.none),
|
||||
backgroundColor: WidgetStatePropertyAll(appTheme.blueA400),
|
||||
),
|
||||
onPressed: () async {
|
||||
showBottomSheetWithBlur(
|
||||
userRootNavigator: true,
|
||||
@@ -146,7 +183,11 @@ class ConsultationWidget extends StatelessWidget {
|
||||
dietName: dietName,
|
||||
onTap: () async {
|
||||
AppRouter.navigatorKey.currentContext?.pop();
|
||||
final result = await context.read<PersonalDietSuggestionCubit>().attachDietToConsultation(consultationId: consultationModel.id);
|
||||
final result = await context
|
||||
.read<PersonalDietSuggestionCubit>()
|
||||
.attachDietToConsultation(
|
||||
consultationId: consultationModel.id,
|
||||
);
|
||||
if (result) {
|
||||
if (!context.mounted) return;
|
||||
showBottomSheetWithBlur(
|
||||
@@ -174,7 +215,7 @@ class ConsultationWidget extends StatelessWidget {
|
||||
),
|
||||
);
|
||||
},
|
||||
)
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -204,35 +245,33 @@ class _SuggestDietBottomSheet extends State<SuggestDietBottomSheet> {
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const SizedBox(
|
||||
height: 8,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const BottomSheetHandlerBar(),
|
||||
const SizedBox(
|
||||
height: 20,
|
||||
),
|
||||
Text('Предложение диеты', style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black)),
|
||||
const SizedBox(
|
||||
height: 16,
|
||||
const SizedBox(height: 20),
|
||||
Text(
|
||||
'Предложение диеты',
|
||||
style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Предложить пользователю ${widget.userName} «${widget.dietName}»',
|
||||
style: TextStyles.textSMedium.copyWith(color: appTheme.black),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(
|
||||
height: 8,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
CustomOutlinedButton(
|
||||
text: s.toOffer,
|
||||
height: 52,
|
||||
buttonTextStyle: TextStyles.textMSemibold.copyWith(color: appTheme.whiteA700),
|
||||
buttonStyle: ButtonStyle(side: const WidgetStatePropertyAll(BorderSide.none), backgroundColor: WidgetStatePropertyAll(appTheme.blueA400)),
|
||||
buttonTextStyle: TextStyles.textMSemibold.copyWith(
|
||||
color: appTheme.whiteA700,
|
||||
),
|
||||
buttonStyle: ButtonStyle(
|
||||
side: const WidgetStatePropertyAll(BorderSide.none),
|
||||
backgroundColor: WidgetStatePropertyAll(appTheme.blueA400),
|
||||
),
|
||||
onPressed: widget.onTap,
|
||||
),
|
||||
const SizedBox(
|
||||
height: 40,
|
||||
),
|
||||
const SizedBox(height: 40),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -249,10 +288,12 @@ class SuggestionSuccessBottomSheet extends StatefulWidget {
|
||||
final String dietName;
|
||||
|
||||
@override
|
||||
State<SuggestionSuccessBottomSheet> createState() => _SuggestionSuccessBottomSheet();
|
||||
State<SuggestionSuccessBottomSheet> createState() =>
|
||||
_SuggestionSuccessBottomSheet();
|
||||
}
|
||||
|
||||
class _SuggestionSuccessBottomSheet extends State<SuggestionSuccessBottomSheet> {
|
||||
class _SuggestionSuccessBottomSheet
|
||||
extends State<SuggestionSuccessBottomSheet> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final s = S.of(context);
|
||||
@@ -261,37 +302,35 @@ class _SuggestionSuccessBottomSheet extends State<SuggestionSuccessBottomSheet>
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const SizedBox(
|
||||
height: 8,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const BottomSheetHandlerBar(),
|
||||
const SizedBox(
|
||||
height: 20,
|
||||
),
|
||||
Text('Предложение диеты', style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black)),
|
||||
const SizedBox(
|
||||
height: 16,
|
||||
const SizedBox(height: 20),
|
||||
Text(
|
||||
'Предложение диеты',
|
||||
style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'«${widget.dietName}» предложена пользователю ${widget.userName}',
|
||||
style: TextStyles.textSMedium.copyWith(color: appTheme.black),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(
|
||||
height: 8,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
CustomOutlinedButton(
|
||||
text: s.ok,
|
||||
height: 52,
|
||||
buttonTextStyle: TextStyles.textMSemibold.copyWith(color: appTheme.whiteA700),
|
||||
buttonStyle: ButtonStyle(side: const WidgetStatePropertyAll(BorderSide.none), backgroundColor: WidgetStatePropertyAll(appTheme.blueA400)),
|
||||
buttonTextStyle: TextStyles.textMSemibold.copyWith(
|
||||
color: appTheme.whiteA700,
|
||||
),
|
||||
buttonStyle: ButtonStyle(
|
||||
side: const WidgetStatePropertyAll(BorderSide.none),
|
||||
backgroundColor: WidgetStatePropertyAll(appTheme.blueA400),
|
||||
),
|
||||
onPressed: () async {
|
||||
AppRouter.navigatorKey.currentContext?.pop();
|
||||
},
|
||||
),
|
||||
const SizedBox(
|
||||
height: 40,
|
||||
),
|
||||
const SizedBox(height: 40),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -308,10 +347,12 @@ class SuggestionFailureBottomSheet extends StatefulWidget {
|
||||
final String dietName;
|
||||
|
||||
@override
|
||||
State<SuggestionFailureBottomSheet> createState() => _SuggestionFailureBottomSheet();
|
||||
State<SuggestionFailureBottomSheet> createState() =>
|
||||
_SuggestionFailureBottomSheet();
|
||||
}
|
||||
|
||||
class _SuggestionFailureBottomSheet extends State<SuggestionFailureBottomSheet> {
|
||||
class _SuggestionFailureBottomSheet
|
||||
extends State<SuggestionFailureBottomSheet> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final s = S.of(context);
|
||||
@@ -320,37 +361,35 @@ class _SuggestionFailureBottomSheet extends State<SuggestionFailureBottomSheet>
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const SizedBox(
|
||||
height: 8,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const BottomSheetHandlerBar(),
|
||||
const SizedBox(
|
||||
height: 20,
|
||||
),
|
||||
Text('Предложение диеты', style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black)),
|
||||
const SizedBox(
|
||||
height: 16,
|
||||
const SizedBox(height: 20),
|
||||
Text(
|
||||
'Предложение диеты',
|
||||
style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Не удалось предложить диету ${widget.dietName} пользователю ${widget.userName}',
|
||||
style: TextStyles.textSMedium.copyWith(color: appTheme.black),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(
|
||||
height: 8,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
CustomOutlinedButton(
|
||||
text: s.ok,
|
||||
height: 52,
|
||||
buttonTextStyle: TextStyles.textMSemibold.copyWith(color: appTheme.whiteA700),
|
||||
buttonStyle: ButtonStyle(side: const WidgetStatePropertyAll(BorderSide.none), backgroundColor: WidgetStatePropertyAll(appTheme.blueA400)),
|
||||
buttonTextStyle: TextStyles.textMSemibold.copyWith(
|
||||
color: appTheme.whiteA700,
|
||||
),
|
||||
buttonStyle: ButtonStyle(
|
||||
side: const WidgetStatePropertyAll(BorderSide.none),
|
||||
backgroundColor: WidgetStatePropertyAll(appTheme.blueA400),
|
||||
),
|
||||
onPressed: () async {
|
||||
AppRouter.navigatorKey.currentContext?.pop();
|
||||
},
|
||||
),
|
||||
const SizedBox(
|
||||
height: 40,
|
||||
),
|
||||
const SizedBox(height: 40),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -196,6 +196,9 @@ class _AboutPlanScreenState extends State<AboutPlanScreen> {
|
||||
isAuthor: state.planModel?.author.authorIsMe ?? false,
|
||||
isAuthorAdmin: state.planModel?.author.isAdmin ?? false,
|
||||
path: 'plan/${widget.slug}',
|
||||
slug: widget.slug,
|
||||
id: state.planModel?.id,
|
||||
plan: state.planModel,
|
||||
complainedContentType: ComplainedContent.plan,
|
||||
userSlug: state.planModel?.author.authorSlug ?? '',
|
||||
),
|
||||
@@ -376,6 +379,9 @@ class _AboutPlanScreenState extends State<AboutPlanScreen> {
|
||||
isAuthor: state.planModel?.author.authorIsMe ?? false,
|
||||
isAuthorAdmin: state.planModel?.author.isAdmin ?? false,
|
||||
path: 'plan/${widget.slug}',
|
||||
slug: widget.slug,
|
||||
id: state.planModel?.id,
|
||||
plan: state.planModel,
|
||||
complainedContentType: ComplainedContent.plan,
|
||||
userSlug: state.planModel?.author.authorSlug ?? '',
|
||||
),
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,6 @@
|
||||
import 'package:eatvio_app/core/app_config.dart';
|
||||
import 'package:eatvio_app/core/locator_service.dart';
|
||||
import 'package:eatvio_app/core/utils/url_utils.dart';
|
||||
import 'package:eatvio_app/features/profile/models/sub_model.dart';
|
||||
import 'package:eatvio_app/features/profile/presentation/widgets/sub_action_button_widget.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
@@ -8,10 +9,7 @@ import 'package:go_router/go_router.dart';
|
||||
class SubWidget extends StatefulWidget {
|
||||
final SubModel subModel;
|
||||
|
||||
const SubWidget({
|
||||
super.key,
|
||||
required this.subModel,
|
||||
});
|
||||
const SubWidget({super.key, required this.subModel});
|
||||
|
||||
@override
|
||||
State<SubWidget> createState() => _SubWidgetState();
|
||||
@@ -21,16 +19,20 @@ class _SubWidgetState extends State<SubWidget> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return InkWell(
|
||||
onTap: () => GoRouter.of(context).pushNamed(
|
||||
'profileScreen',
|
||||
extra: widget.subModel.slug,
|
||||
),
|
||||
onTap: () => GoRouter.of(
|
||||
context,
|
||||
).pushNamed('profileScreen', extra: widget.subModel.slug),
|
||||
child: Ink(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
|
||||
child: Row(
|
||||
children: [
|
||||
CircleAvatar(
|
||||
foregroundImage: NetworkImage(sl<AppConfig>().baseUrl + widget.subModel.profilePhotoUrl),
|
||||
foregroundImage: NetworkImage(
|
||||
joinBaseUrl(
|
||||
sl<AppConfig>().baseUrl,
|
||||
widget.subModel.profilePhotoUrl,
|
||||
),
|
||||
),
|
||||
backgroundImage: const AssetImage('assets/images/no_avatar.png'),
|
||||
radius: 28,
|
||||
),
|
||||
@@ -62,7 +64,9 @@ class _SubWidgetState extends State<SubWidget> {
|
||||
),
|
||||
const Spacer(),
|
||||
SubActionButtonWidget(
|
||||
subActionType: widget.subModel.isEntitySubscribedByMe ? SubActionType.sendMessage : SubActionType.subscribe,
|
||||
subActionType: widget.subModel.isEntitySubscribedByMe
|
||||
? SubActionType.sendMessage
|
||||
: SubActionType.subscribe,
|
||||
slug: widget.subModel.slug,
|
||||
name: widget.subModel.name,
|
||||
porfileIcon: widget.subModel.profilePhotoUrl,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
name: eatvio_app
|
||||
description: A new Flutter project.
|
||||
version: 1.3.47+136
|
||||
version: 1.3.50+139
|
||||
publish_to: 'none'
|
||||
|
||||
environment:
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'dart:io';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:eatvio_app/core/error_reporting/error_report_policy.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:sentry_flutter/sentry_flutter.dart';
|
||||
|
||||
@@ -72,6 +73,24 @@ void main() {
|
||||
test('returns true for arbitrary exceptions', () {
|
||||
expect(ErrorReportPolicy.shouldReport(StateError('x')), true);
|
||||
});
|
||||
|
||||
test('returns false for HMS client api invalid', () {
|
||||
expect(
|
||||
ErrorReportPolicy.shouldReport(
|
||||
PlatformException(code: 'HMS_TOKEN_ERROR', message: '907135003: client api invalid'),
|
||||
),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('returns true for other HMS token errors', () {
|
||||
expect(
|
||||
ErrorReportPolicy.shouldReport(
|
||||
PlatformException(code: 'HMS_TOKEN_ERROR', message: 'Missing Huawei appId'),
|
||||
),
|
||||
true,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('ErrorReportPolicy.allowSentryEvent', () {
|
||||
@@ -121,6 +140,13 @@ void main() {
|
||||
expect(ErrorReportPolicy.allowSentryEvent(event, hint), true);
|
||||
});
|
||||
|
||||
test('drops HMS client api invalid before send', () {
|
||||
final event = SentryEvent(
|
||||
throwable: PlatformException(code: 'HMS_TOKEN_ERROR', message: '907135003: client api invalid'),
|
||||
);
|
||||
expect(ErrorReportPolicy.allowSentryEvent(event, hint), false);
|
||||
});
|
||||
|
||||
test('drops cancelled Dio from SDK', () {
|
||||
final e = DioException(
|
||||
requestOptions: RequestOptions(path: '/x'),
|
||||
|
||||
112
test/core/push/hms_client_error_test.dart
Normal file
112
test/core/push/hms_client_error_test.dart
Normal file
@@ -0,0 +1,112 @@
|
||||
import 'package:eatvio_app/core/push/hms_client_error.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
group('isHuaweiFamilyBrand', () {
|
||||
test('matches Huawei and Honor brand or manufacturer', () {
|
||||
expect(
|
||||
isHuaweiFamilyBrand(brand: 'HONOR', manufacturer: 'HONOR'),
|
||||
isTrue,
|
||||
);
|
||||
expect(
|
||||
isHuaweiFamilyBrand(brand: 'Huawei', manufacturer: 'HUAWEI'),
|
||||
isTrue,
|
||||
);
|
||||
expect(
|
||||
isHuaweiFamilyBrand(brand: 'samsung', manufacturer: 'HONOR'),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
test('does not match other Android brands', () {
|
||||
expect(
|
||||
isHuaweiFamilyBrand(brand: 'samsung', manufacturer: 'Samsung'),
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('isHmsClientUnavailable', () {
|
||||
test('matches 907135003 client api invalid', () {
|
||||
expect(
|
||||
isHmsClientUnavailable(
|
||||
PlatformException(
|
||||
code: 'HMS_TOKEN_ERROR',
|
||||
message: '907135003: client api invalid',
|
||||
),
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
test('does not match a missing app id', () {
|
||||
expect(
|
||||
isHmsClientUnavailable(
|
||||
PlatformException(
|
||||
code: 'HMS_TOKEN_ERROR',
|
||||
message: 'Missing Huawei appId',
|
||||
),
|
||||
),
|
||||
isFalse,
|
||||
);
|
||||
expect(isHmsClientUnavailable(StateError('907135003')), isFalse);
|
||||
});
|
||||
});
|
||||
|
||||
group('pushPlatform', () {
|
||||
test('uses huawei only when an HMS token exists', () {
|
||||
expect(
|
||||
pushPlatform(
|
||||
isIos: false,
|
||||
huaweiFamily: true,
|
||||
hmsAvailable: true,
|
||||
hasHmsToken: true,
|
||||
),
|
||||
'huawei',
|
||||
);
|
||||
});
|
||||
|
||||
test('falls back to android on Honor without a valid HMS client', () {
|
||||
expect(
|
||||
pushPlatform(
|
||||
isIos: false,
|
||||
huaweiFamily: true,
|
||||
hmsAvailable: false,
|
||||
hasHmsToken: false,
|
||||
),
|
||||
'android',
|
||||
);
|
||||
expect(
|
||||
pushPlatform(
|
||||
isIos: false,
|
||||
huaweiFamily: true,
|
||||
hmsAvailable: true,
|
||||
hasHmsToken: false,
|
||||
),
|
||||
'android',
|
||||
);
|
||||
});
|
||||
|
||||
test('keeps ios and plain android', () {
|
||||
expect(
|
||||
pushPlatform(
|
||||
isIos: true,
|
||||
huaweiFamily: false,
|
||||
hmsAvailable: false,
|
||||
hasHmsToken: false,
|
||||
),
|
||||
'ios',
|
||||
);
|
||||
expect(
|
||||
pushPlatform(
|
||||
isIos: false,
|
||||
huaweiFamily: false,
|
||||
hmsAvailable: false,
|
||||
hasHmsToken: false,
|
||||
),
|
||||
'android',
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
26
test/core/utils/url_utils_test.dart
Normal file
26
test/core/utils/url_utils_test.dart
Normal file
@@ -0,0 +1,26 @@
|
||||
import 'package:eatvio_app/core/utils/url_utils.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
const baseUrl = 'https://dev2.eatvio.com';
|
||||
|
||||
test('keeps an absolute default avatar url unchanged', () {
|
||||
const avatar =
|
||||
'https://ui-avatars.dev2.eatvio.com/api/?name=%D0%9A+%D0%90&color=7F9CF5&background=EBF4FF';
|
||||
|
||||
expect(joinBaseUrl(baseUrl, avatar), avatar);
|
||||
});
|
||||
|
||||
test('keeps an absolute http url unchanged', () {
|
||||
const photo = 'http://localhost/storage/avatar.jpg';
|
||||
|
||||
expect(joinBaseUrl(baseUrl, photo), photo);
|
||||
});
|
||||
|
||||
test('prefixes a relative storage path with the base url', () {
|
||||
expect(
|
||||
joinBaseUrl(baseUrl, '/storage/profile-photos/photo.jpg'),
|
||||
'https://dev2.eatvio.com/storage/profile-photos/photo.jpg',
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -4,6 +4,9 @@ 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/custom_popup.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/feed/models/recipes_model.dart';
|
||||
import 'package:eatvio_app/generated/l10n.dart';
|
||||
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
@@ -185,6 +188,68 @@ void main() {
|
||||
expect(find.text('Удалить'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('CustomPopup for author recipe shows edit when recipe is provided', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
wrap(
|
||||
CustomPopup(
|
||||
isAuthor: true,
|
||||
complainedContentType: ComplainedContent.recipe,
|
||||
path: 'recipes/slug',
|
||||
slug: 'slug',
|
||||
recipe: _recipe(),
|
||||
userSlug: 'author',
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.tap(find.byIcon(Icons.more_horiz_rounded));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Редактировать'), findsOneWidget);
|
||||
expect(find.text('Удалить'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('CustomPopup for author diet shows edit when diet is provided', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
wrap(
|
||||
CustomPopup(
|
||||
isAuthor: true,
|
||||
complainedContentType: ComplainedContent.diet,
|
||||
path: 'diets/slug',
|
||||
id: 1,
|
||||
diet: _diet(),
|
||||
userSlug: 'author',
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.tap(find.byIcon(Icons.more_horiz_rounded));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Редактировать'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('CustomPopup for author plan shows edit when plan is provided', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
wrap(
|
||||
CustomPopup(
|
||||
isAuthor: true,
|
||||
complainedContentType: ComplainedContent.plan,
|
||||
path: 'plans/slug',
|
||||
slug: 'slug',
|
||||
plan: _plan(),
|
||||
userSlug: 'author',
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.tap(find.byIcon(Icons.more_horiz_rounded));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Редактировать'), findsOneWidget);
|
||||
expect(find.text('Удалить'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('CustomPopup for non-author without complained type still renders static actions', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
wrap(
|
||||
@@ -206,5 +271,72 @@ void main() {
|
||||
expect(find.text('Пожаловаться'), findsOneWidget);
|
||||
expect(find.text('Заблокировать автора'), findsOneWidget);
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
Recipe _recipe() {
|
||||
return const Recipe(
|
||||
id: 1,
|
||||
image: 'image.jpg',
|
||||
state: 'active',
|
||||
cookingTime: 30,
|
||||
activeCookingTime: 15,
|
||||
ingredients: null,
|
||||
description: 'Описание',
|
||||
steps: null,
|
||||
videoUrl: null,
|
||||
title: 'Рецепт',
|
||||
type: 'main',
|
||||
slug: 'recipe-slug',
|
||||
commentsCount: 0,
|
||||
wasLikedByMe: false,
|
||||
isFavorite: false,
|
||||
calories: 100,
|
||||
likesCount: 0,
|
||||
viewsCount: 0,
|
||||
tags: [],
|
||||
author: Author(authorSlug: 'author', authorName: 'Author', authorProfileIcon: 'icon.jpg', authorIsMe: true),
|
||||
);
|
||||
}
|
||||
|
||||
Diet _diet() {
|
||||
return const Diet(
|
||||
id: 1,
|
||||
slug: 'diet-slug',
|
||||
name: 'Диета',
|
||||
image: 'image.jpg',
|
||||
duration: 7,
|
||||
state: DietState.active,
|
||||
type: DietType(),
|
||||
details: 'Описание',
|
||||
dailySchedule: {},
|
||||
author: Author(authorSlug: 'author', authorName: 'Author', authorProfileIcon: 'icon.jpg', authorIsMe: true),
|
||||
totalLikes: 0,
|
||||
totalViews: 0,
|
||||
totalComments: 0,
|
||||
wasLikedByMe: false,
|
||||
isFavorite: false,
|
||||
dailyNutrition: [],
|
||||
);
|
||||
}
|
||||
|
||||
PlanModel _plan() {
|
||||
return const PlanModel(
|
||||
id: 1,
|
||||
slug: 'plan-slug',
|
||||
name: 'План',
|
||||
image: 'image.jpg',
|
||||
duration: 7,
|
||||
state: 'active',
|
||||
type: PlanType(fit: true, slim: false, behavior: false, wellness: false),
|
||||
details: 'Описание',
|
||||
packetsCount: 1,
|
||||
author: Author(authorSlug: 'author', authorName: 'Author', authorProfileIcon: 'icon.jpg', authorIsMe: true),
|
||||
totalLikes: 0,
|
||||
totalViews: 0,
|
||||
totalComments: 0,
|
||||
isFavorite: false,
|
||||
minPriceOfOptions: 1200,
|
||||
maxPriceOfOptions: 1200,
|
||||
wasLikedByMe: false,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import 'package:eatvio_app/features/feed/presentation/bloc/feed_cubit/feed_state
|
||||
import 'package:eatvio_app/generated/l10n.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_keyboard_visibility/flutter_keyboard_visibility.dart';
|
||||
import 'package:flutter_localizations/flutter_localizations.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
@@ -60,6 +61,7 @@ void main() {
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
KeyboardVisibilityTesting.setVisibilityForTesting(false);
|
||||
blogPostCreateCubit.close();
|
||||
});
|
||||
|
||||
@@ -128,12 +130,16 @@ void main() {
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('does not add extra space above the title field', (tester) async {
|
||||
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;
|
||||
final scrollTop = tester
|
||||
.getTopLeft(find.byType(SingleChildScrollView))
|
||||
.dy;
|
||||
expect(titleTop - scrollTop, 16);
|
||||
});
|
||||
|
||||
@@ -281,6 +287,44 @@ void main() {
|
||||
expect(find.text('Создать'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'keeps create button above the editor toolbar when the keyboard is visible',
|
||||
(tester) async {
|
||||
await tester.pumpWidget(createTestWidget());
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
KeyboardVisibilityTesting.setVisibilityForTesting(true);
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
|
||||
final createButton = find.byKey(
|
||||
AppKeys.blogPostCreateScreenCreateButton,
|
||||
);
|
||||
final editorToolbar = find.byKey(
|
||||
const ValueKey('blogPostCreateEditorToolbar'),
|
||||
);
|
||||
|
||||
expect(createButton, findsOneWidget);
|
||||
expect(editorToolbar, findsOneWidget);
|
||||
|
||||
final buttonRect = tester.getRect(createButton);
|
||||
final toolbarRect = tester.getRect(editorToolbar);
|
||||
|
||||
expect(buttonRect.overlaps(toolbarRect), isFalse);
|
||||
expect(buttonRect.top, greaterThanOrEqualTo(toolbarRect.bottom));
|
||||
|
||||
await tester.tap(createButton);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(
|
||||
find.text(
|
||||
'Добавьте заголовок, описание, изображение или ссылку на видео',
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('does not submit when form is empty', (tester) async {
|
||||
when(
|
||||
() => mockBlogPostRepository.createBlogPost(
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import 'package:eatvio_app/features/create_plan/presentation/cubit/create_plan_cubit.dart';
|
||||
import 'package:eatvio_app/features/create_plan/repositories/create_plan_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:flutter_image_compress/flutter_image_compress.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:mockito/annotations.dart';
|
||||
@@ -703,6 +705,51 @@ void main() {
|
||||
expect(cubit.state.planCreated, false);
|
||||
});
|
||||
|
||||
test('loadExisting заполняет форму и обновляет существующий план', () async {
|
||||
const plan = PlanModel(
|
||||
id: 7,
|
||||
slug: 'test-plan',
|
||||
name: 'Test Plan',
|
||||
image: 'cover.jpg',
|
||||
duration: 14,
|
||||
state: 'active',
|
||||
type: PlanType(fit: true, slim: true, behavior: false, wellness: false),
|
||||
details: 'Описание плана',
|
||||
packetsCount: 1,
|
||||
packet1Options: PacketOptions(
|
||||
items: ['Консультации'],
|
||||
price: 1500,
|
||||
short: 'Базовый',
|
||||
description: 'Описание опции',
|
||||
hasPersonalDiet: false,
|
||||
),
|
||||
author: Author(authorSlug: 'author', authorName: 'Author', authorProfileIcon: 'icon.jpg', authorIsMe: true),
|
||||
totalLikes: 0,
|
||||
totalViews: 0,
|
||||
totalComments: 0,
|
||||
isFavorite: false,
|
||||
minPriceOfOptions: 1500,
|
||||
maxPriceOfOptions: 1500,
|
||||
wasLikedByMe: false,
|
||||
);
|
||||
|
||||
when(repository.updatePublishedPlan(slug: anyNamed('slug'), model: anyNamed('model'))).thenAnswer((_) async {});
|
||||
|
||||
cubit.loadExisting(plan);
|
||||
expect(cubit.sourcePlan?.slug, 'test-plan');
|
||||
expect(cubit.state.planData.name, 'Test Plan');
|
||||
expect(cubit.state.planData.duration, 14);
|
||||
expect(cubit.state.durationIndex, 1);
|
||||
expect(cubit.state.planData.type, [true, true, false, false]);
|
||||
expect(cubit.validateImage(), true);
|
||||
|
||||
await cubit.publishPlan();
|
||||
|
||||
verify(repository.updatePublishedPlan(slug: 'test-plan', model: anyNamed('model'))).called(1);
|
||||
verifyNever(repository.createPlan(model: anyNamed('model')));
|
||||
expect(cubit.state.planCreated, true);
|
||||
});
|
||||
|
||||
test('setPlanType - валидирует тип когда хотя бы один выбран', () {
|
||||
cubit.setPlanType([true, false, false, false]);
|
||||
expect(cubit.state.validationState.typeHasError, false);
|
||||
|
||||
60
test/features/diary/presentation/diary_edit_access_test.dart
Normal file
60
test/features/diary/presentation/diary_edit_access_test.dart
Normal file
@@ -0,0 +1,60 @@
|
||||
import 'package:eatvio_app/features/diary/presentation/diary_edit_access.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
final now = DateTime(2026, 9, 30, 12);
|
||||
|
||||
bool canEdit({
|
||||
bool isConsultantView = false,
|
||||
bool isDiaryCommented = false,
|
||||
bool isDiaryFinished = false,
|
||||
int closed = 0,
|
||||
String diaryDate = '2026-09-28',
|
||||
}) {
|
||||
return canEditDiaryDay(
|
||||
isConsultantView: isConsultantView,
|
||||
isDiaryCommented: isDiaryCommented,
|
||||
isDiaryFinished: isDiaryFinished,
|
||||
closed: closed,
|
||||
diaryDate: diaryDate,
|
||||
now: now,
|
||||
);
|
||||
}
|
||||
|
||||
test('finished days inside the seven-day window stay editable', () {
|
||||
expect(canEdit(isDiaryFinished: true, diaryDate: '2026-09-30'), isTrue);
|
||||
expect(canEdit(isDiaryFinished: true, diaryDate: '2026-09-29'), isTrue);
|
||||
expect(canEdit(isDiaryFinished: true, diaryDate: '2026-09-23'), isTrue);
|
||||
});
|
||||
|
||||
test('finished days older than the seven-day window stay read-only', () {
|
||||
expect(canEdit(isDiaryFinished: true, diaryDate: '2026-09-22'), isFalse);
|
||||
});
|
||||
|
||||
test('commented days stay read-only inside the window', () {
|
||||
expect(canEdit(isDiaryCommented: true, diaryDate: '2026-09-30'), isFalse);
|
||||
expect(
|
||||
canEdit(
|
||||
isDiaryCommented: true,
|
||||
isDiaryFinished: true,
|
||||
diaryDate: '2026-09-29',
|
||||
),
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
|
||||
test('an open day outside the window stays editable', () {
|
||||
expect(canEdit(diaryDate: '2026-09-01'), isTrue);
|
||||
});
|
||||
|
||||
test('a closed flag blocks editing even inside the window', () {
|
||||
expect(
|
||||
canEdit(isDiaryFinished: true, closed: 1, diaryDate: '2026-09-30'),
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
|
||||
test('consultant view stays read-only', () {
|
||||
expect(canEdit(isConsultantView: true, diaryDate: '2026-09-30'), isFalse);
|
||||
});
|
||||
}
|
||||
@@ -25,16 +25,25 @@ import 'package:mockito/mockito.dart';
|
||||
|
||||
import 'diary_screen_smoke_test.mocks.dart';
|
||||
|
||||
@GenerateNiceMocks([MockSpec<DiaryCubit>(), MockSpec<ConsultantCommentsCubit>()])
|
||||
@GenerateNiceMocks([
|
||||
MockSpec<DiaryCubit>(),
|
||||
MockSpec<ConsultantCommentsCubit>(),
|
||||
])
|
||||
void main() {
|
||||
group('DiaryScreen 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'),
|
||||
);
|
||||
});
|
||||
|
||||
Widget wrap(Widget child) {
|
||||
@@ -51,23 +60,53 @@ void main() {
|
||||
);
|
||||
}
|
||||
|
||||
DiaryStateLoaded loadedState({required bool hasChosenPlan}) {
|
||||
DiaryStateLoaded loadedState({
|
||||
required bool hasChosenPlan,
|
||||
String date = '2025-01-01',
|
||||
bool isDiaryFinished = false,
|
||||
bool isDiaryCommented = false,
|
||||
int closed = 0,
|
||||
}) {
|
||||
final diary = DiaryDayEntity(
|
||||
id: 1,
|
||||
date: '2025-01-01',
|
||||
date: date,
|
||||
wakeup: '07:00',
|
||||
retire: '23:00',
|
||||
closed: 0,
|
||||
closed: closed,
|
||||
comment: '',
|
||||
typeOfDiary: TypeOfDiaryModel(hasChosenDiet: false, hasChosenPlan: hasChosenPlan, diaryState: null),
|
||||
meals: const [MealModel(id: 1, time: '08:00', typeNumber: 1, satiety: 0, mood: 0, portions: [], photos: [])],
|
||||
typeOfDiary: TypeOfDiaryModel(
|
||||
hasChosenDiet: false,
|
||||
hasChosenPlan: hasChosenPlan,
|
||||
diaryState: null,
|
||||
),
|
||||
meals: const [
|
||||
MealModel(
|
||||
id: 1,
|
||||
time: '08:00',
|
||||
typeNumber: 1,
|
||||
satiety: 0,
|
||||
mood: 0,
|
||||
portions: [],
|
||||
photos: [],
|
||||
),
|
||||
],
|
||||
chosenPlanId: hasChosenPlan ? 10 : null,
|
||||
dayOfChosenDiet: null,
|
||||
diet: null,
|
||||
);
|
||||
const nutrition = NutritionDataModel(
|
||||
dailyNutritionIntake: NutritionModel(energy: 0, proteins: 0, fats: 0, carbohydrates: 0),
|
||||
dailyNutritionRecommendation: NutritionModel(energy: 0, proteins: 0, fats: 0, carbohydrates: 0),
|
||||
dailyNutritionIntake: NutritionModel(
|
||||
energy: 0,
|
||||
proteins: 0,
|
||||
fats: 0,
|
||||
carbohydrates: 0,
|
||||
),
|
||||
dailyNutritionRecommendation: NutritionModel(
|
||||
energy: 0,
|
||||
proteins: 0,
|
||||
fats: 0,
|
||||
carbohydrates: 0,
|
||||
),
|
||||
);
|
||||
return DiaryState.loaded(
|
||||
diary: diary,
|
||||
@@ -75,8 +114,8 @@ void main() {
|
||||
selectedDay: DateTime(2025, 1, 1),
|
||||
selectedMeal: 1,
|
||||
isConsultantView: false,
|
||||
isDiaryFinished: false,
|
||||
isDiaryCommented: false,
|
||||
isDiaryFinished: isDiaryFinished,
|
||||
isDiaryCommented: isDiaryCommented,
|
||||
isWakeUpAndRetireEnabled: true,
|
||||
isMoodEnabled: true,
|
||||
)
|
||||
@@ -86,13 +125,26 @@ void main() {
|
||||
testWidgets('renders loading state', (tester) async {
|
||||
final diaryCubit = MockDiaryCubit();
|
||||
when(diaryCubit.state).thenReturn(const DiaryState.loading());
|
||||
when(diaryCubit.stream).thenAnswer((_) => const Stream<DiaryState>.empty());
|
||||
when(
|
||||
diaryCubit.stream,
|
||||
).thenAnswer((_) => const Stream<DiaryState>.empty());
|
||||
when(diaryCubit.close()).thenAnswer((_) async => Future<void>.value());
|
||||
when(diaryCubit.fetchDiaryV2(needLoading: anyNamed('needLoading'), diaryId: anyNamed('diaryId'))).thenAnswer((_) async => true);
|
||||
when(
|
||||
diaryCubit.fetchDiaryV2(
|
||||
needLoading: anyNamed('needLoading'),
|
||||
diaryId: anyNamed('diaryId'),
|
||||
),
|
||||
).thenAnswer((_) async => true);
|
||||
|
||||
final commentsCubit = MockConsultantCommentsCubit();
|
||||
when(commentsCubit.state).thenReturn(ConsultantCommentsState(commentsModel: ConsultantCommentModel(comment: '')));
|
||||
when(commentsCubit.stream).thenAnswer((_) => const Stream<ConsultantCommentsState>.empty());
|
||||
when(commentsCubit.state).thenReturn(
|
||||
ConsultantCommentsState(
|
||||
commentsModel: ConsultantCommentModel(comment: ''),
|
||||
),
|
||||
);
|
||||
when(
|
||||
commentsCubit.stream,
|
||||
).thenAnswer((_) => const Stream<ConsultantCommentsState>.empty());
|
||||
when(commentsCubit.close()).thenAnswer((_) async => Future<void>.value());
|
||||
|
||||
sl.registerFactory<DiaryCubit>(() => diaryCubit);
|
||||
@@ -107,13 +159,27 @@ void main() {
|
||||
testWidgets('renders loaded state (chosen plan)', (tester) async {
|
||||
final diaryCubit = MockDiaryCubit();
|
||||
when(diaryCubit.state).thenReturn(loadedState(hasChosenPlan: true));
|
||||
when(diaryCubit.stream).thenAnswer((_) => const Stream<DiaryState>.empty());
|
||||
when(
|
||||
diaryCubit.stream,
|
||||
).thenAnswer((_) => const Stream<DiaryState>.empty());
|
||||
when(diaryCubit.close()).thenAnswer((_) async => Future<void>.value());
|
||||
when(diaryCubit.fetchDiaryV2(needLoading: anyNamed('needLoading'), diaryId: anyNamed('diaryId'))).thenAnswer((_) async => true);
|
||||
when(
|
||||
diaryCubit.fetchDiaryV2(
|
||||
needLoading: anyNamed('needLoading'),
|
||||
diaryId: anyNamed('diaryId'),
|
||||
),
|
||||
).thenAnswer((_) async => true);
|
||||
|
||||
final commentsCubit = MockConsultantCommentsCubit();
|
||||
when(commentsCubit.state).thenReturn(ConsultantCommentsState(commentsModel: ConsultantCommentModel(comment: ''), dayCommentNeedsToBeFilled: false));
|
||||
when(commentsCubit.stream).thenAnswer((_) => const Stream<ConsultantCommentsState>.empty());
|
||||
when(commentsCubit.state).thenReturn(
|
||||
ConsultantCommentsState(
|
||||
commentsModel: ConsultantCommentModel(comment: ''),
|
||||
dayCommentNeedsToBeFilled: false,
|
||||
),
|
||||
);
|
||||
when(
|
||||
commentsCubit.stream,
|
||||
).thenAnswer((_) => const Stream<ConsultantCommentsState>.empty());
|
||||
when(commentsCubit.close()).thenAnswer((_) async => Future<void>.value());
|
||||
|
||||
sl.registerFactory<DiaryCubit>(() => diaryCubit);
|
||||
@@ -124,19 +190,37 @@ void main() {
|
||||
|
||||
expect(find.byType(DiaryScreen), findsOneWidget);
|
||||
|
||||
expect(find.text('Дополнительная информация для консультанта'), findsOneWidget);
|
||||
expect(
|
||||
find.text('Дополнительная информация для консультанта'),
|
||||
findsOneWidget,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('renders error state and reload button', (tester) async {
|
||||
final diaryCubit = MockDiaryCubit();
|
||||
when(diaryCubit.state).thenReturn(const DiaryState.error(errorMessage: 'err'));
|
||||
when(diaryCubit.stream).thenAnswer((_) => const Stream<DiaryState>.empty());
|
||||
when(
|
||||
diaryCubit.state,
|
||||
).thenReturn(const DiaryState.error(errorMessage: 'err'));
|
||||
when(
|
||||
diaryCubit.stream,
|
||||
).thenAnswer((_) => const Stream<DiaryState>.empty());
|
||||
when(diaryCubit.close()).thenAnswer((_) async => Future<void>.value());
|
||||
when(diaryCubit.fetchDiaryV2(needLoading: anyNamed('needLoading'), diaryId: anyNamed('diaryId'))).thenAnswer((_) async => true);
|
||||
when(
|
||||
diaryCubit.fetchDiaryV2(
|
||||
needLoading: anyNamed('needLoading'),
|
||||
diaryId: anyNamed('diaryId'),
|
||||
),
|
||||
).thenAnswer((_) async => true);
|
||||
|
||||
final commentsCubit = MockConsultantCommentsCubit();
|
||||
when(commentsCubit.state).thenReturn(ConsultantCommentsState(commentsModel: ConsultantCommentModel(comment: '')));
|
||||
when(commentsCubit.stream).thenAnswer((_) => const Stream<ConsultantCommentsState>.empty());
|
||||
when(commentsCubit.state).thenReturn(
|
||||
ConsultantCommentsState(
|
||||
commentsModel: ConsultantCommentModel(comment: ''),
|
||||
),
|
||||
);
|
||||
when(
|
||||
commentsCubit.stream,
|
||||
).thenAnswer((_) => const Stream<ConsultantCommentsState>.empty());
|
||||
when(commentsCubit.close()).thenAnswer((_) async => Future<void>.value());
|
||||
|
||||
sl.registerFactory<DiaryCubit>(() => diaryCubit);
|
||||
@@ -153,19 +237,52 @@ void main() {
|
||||
final diaryCubit = MockDiaryCubit();
|
||||
const futureDiary = DiaryFutureDietDayEntity(
|
||||
date: '2025-01-02',
|
||||
typeOfDiary: TypeOfDiaryModel(hasChosenDiet: true, hasChosenPlan: false, diaryState: null),
|
||||
meals: [MealModel(id: 1, time: '08:00', typeNumber: 1, satiety: 0, mood: 0, portions: [], photos: [])],
|
||||
typeOfDiary: TypeOfDiaryModel(
|
||||
hasChosenDiet: true,
|
||||
hasChosenPlan: false,
|
||||
diaryState: null,
|
||||
),
|
||||
meals: [
|
||||
MealModel(
|
||||
id: 1,
|
||||
time: '08:00',
|
||||
typeNumber: 1,
|
||||
satiety: 0,
|
||||
mood: 0,
|
||||
portions: [],
|
||||
photos: [],
|
||||
),
|
||||
],
|
||||
diet: null,
|
||||
dayOfChosenDiet: 1,
|
||||
);
|
||||
when(diaryCubit.state).thenReturn(DiaryState.loadedTomorrowWithDiet(diary: futureDiary, selectedDay: DateTime(2025, 1, 2), selectedMeal: 1));
|
||||
when(diaryCubit.stream).thenAnswer((_) => const Stream<DiaryState>.empty());
|
||||
when(diaryCubit.state).thenReturn(
|
||||
DiaryState.loadedTomorrowWithDiet(
|
||||
diary: futureDiary,
|
||||
selectedDay: DateTime(2025, 1, 2),
|
||||
selectedMeal: 1,
|
||||
),
|
||||
);
|
||||
when(
|
||||
diaryCubit.stream,
|
||||
).thenAnswer((_) => const Stream<DiaryState>.empty());
|
||||
when(diaryCubit.close()).thenAnswer((_) async => Future<void>.value());
|
||||
when(diaryCubit.fetchDiaryV2(needLoading: anyNamed('needLoading'), diaryId: anyNamed('diaryId'))).thenAnswer((_) async => true);
|
||||
when(
|
||||
diaryCubit.fetchDiaryV2(
|
||||
needLoading: anyNamed('needLoading'),
|
||||
diaryId: anyNamed('diaryId'),
|
||||
),
|
||||
).thenAnswer((_) async => true);
|
||||
|
||||
final commentsCubit = MockConsultantCommentsCubit();
|
||||
when(commentsCubit.state).thenReturn(ConsultantCommentsState(commentsModel: ConsultantCommentModel(comment: '')));
|
||||
when(commentsCubit.stream).thenAnswer((_) => const Stream<ConsultantCommentsState>.empty());
|
||||
when(commentsCubit.state).thenReturn(
|
||||
ConsultantCommentsState(
|
||||
commentsModel: ConsultantCommentModel(comment: ''),
|
||||
),
|
||||
);
|
||||
when(
|
||||
commentsCubit.stream,
|
||||
).thenAnswer((_) => const Stream<ConsultantCommentsState>.empty());
|
||||
when(commentsCubit.close()).thenAnswer((_) async => Future<void>.value());
|
||||
|
||||
sl.registerFactory<DiaryCubit>(() => diaryCubit);
|
||||
@@ -187,17 +304,35 @@ void main() {
|
||||
planName: 'Plan',
|
||||
planDuration: 30,
|
||||
currentPlanDay: 1,
|
||||
consultantComments: ConsultantComments(dayComment: 'Nice', mealsComments: const [], portionsComments: const []),
|
||||
consultantComments: ConsultantComments(
|
||||
dayComment: 'Nice',
|
||||
mealsComments: const [],
|
||||
portionsComments: const [],
|
||||
),
|
||||
),
|
||||
);
|
||||
when(diaryCubit.state).thenReturn(state);
|
||||
when(diaryCubit.stream).thenAnswer((_) => const Stream<DiaryState>.empty());
|
||||
when(
|
||||
diaryCubit.stream,
|
||||
).thenAnswer((_) => const Stream<DiaryState>.empty());
|
||||
when(diaryCubit.close()).thenAnswer((_) async => Future<void>.value());
|
||||
when(diaryCubit.fetchDiaryV2(needLoading: anyNamed('needLoading'), diaryId: anyNamed('diaryId'))).thenAnswer((_) async => true);
|
||||
when(
|
||||
diaryCubit.fetchDiaryV2(
|
||||
needLoading: anyNamed('needLoading'),
|
||||
diaryId: anyNamed('diaryId'),
|
||||
),
|
||||
).thenAnswer((_) async => true);
|
||||
|
||||
final commentsCubit = MockConsultantCommentsCubit();
|
||||
when(commentsCubit.state).thenReturn(ConsultantCommentsState(commentsModel: ConsultantCommentModel(comment: ''), dayCommentNeedsToBeFilled: false));
|
||||
when(commentsCubit.stream).thenAnswer((_) => const Stream<ConsultantCommentsState>.empty());
|
||||
when(commentsCubit.state).thenReturn(
|
||||
ConsultantCommentsState(
|
||||
commentsModel: ConsultantCommentModel(comment: ''),
|
||||
dayCommentNeedsToBeFilled: false,
|
||||
),
|
||||
);
|
||||
when(
|
||||
commentsCubit.stream,
|
||||
).thenAnswer((_) => const Stream<ConsultantCommentsState>.empty());
|
||||
when(commentsCubit.close()).thenAnswer((_) async => Future<void>.value());
|
||||
when(commentsCubit.submitComments(any)).thenAnswer((_) async => true);
|
||||
|
||||
@@ -215,13 +350,26 @@ void main() {
|
||||
final diaryCubit = MockDiaryCubit();
|
||||
final state = loadedState(hasChosenPlan: false);
|
||||
when(diaryCubit.state).thenReturn(state);
|
||||
when(diaryCubit.stream).thenAnswer((_) => const Stream<DiaryState>.empty());
|
||||
when(
|
||||
diaryCubit.stream,
|
||||
).thenAnswer((_) => const Stream<DiaryState>.empty());
|
||||
when(diaryCubit.close()).thenAnswer((_) async => Future<void>.value());
|
||||
when(diaryCubit.fetchDiaryV2(needLoading: anyNamed('needLoading'), diaryId: anyNamed('diaryId'))).thenAnswer((_) async => true);
|
||||
when(
|
||||
diaryCubit.fetchDiaryV2(
|
||||
needLoading: anyNamed('needLoading'),
|
||||
diaryId: anyNamed('diaryId'),
|
||||
),
|
||||
).thenAnswer((_) async => true);
|
||||
|
||||
final commentsCubit = MockConsultantCommentsCubit();
|
||||
when(commentsCubit.state).thenReturn(ConsultantCommentsState(commentsModel: ConsultantCommentModel(comment: '')));
|
||||
when(commentsCubit.stream).thenAnswer((_) => const Stream<ConsultantCommentsState>.empty());
|
||||
when(commentsCubit.state).thenReturn(
|
||||
ConsultantCommentsState(
|
||||
commentsModel: ConsultantCommentModel(comment: ''),
|
||||
),
|
||||
);
|
||||
when(
|
||||
commentsCubit.stream,
|
||||
).thenAnswer((_) => const Stream<ConsultantCommentsState>.empty());
|
||||
when(commentsCubit.close()).thenAnswer((_) async => Future<void>.value());
|
||||
|
||||
sl.registerFactory<DiaryCubit>(() => diaryCubit);
|
||||
@@ -234,5 +382,96 @@ void main() {
|
||||
|
||||
expect(find.byType(Text), findsWidgets);
|
||||
});
|
||||
|
||||
testWidgets('shows add button for a finished day inside the edit window', (
|
||||
tester,
|
||||
) async {
|
||||
final yesterday = DateTime.now().subtract(const Duration(days: 1));
|
||||
final diaryCubit = MockDiaryCubit();
|
||||
when(diaryCubit.state).thenReturn(
|
||||
loadedState(
|
||||
hasChosenPlan: false,
|
||||
date: _calendarDate(yesterday),
|
||||
isDiaryFinished: true,
|
||||
),
|
||||
);
|
||||
when(
|
||||
diaryCubit.stream,
|
||||
).thenAnswer((_) => const Stream<DiaryState>.empty());
|
||||
when(diaryCubit.close()).thenAnswer((_) async => Future<void>.value());
|
||||
when(
|
||||
diaryCubit.fetchDiaryV2(
|
||||
needLoading: anyNamed('needLoading'),
|
||||
diaryId: anyNamed('diaryId'),
|
||||
),
|
||||
).thenAnswer((_) async => true);
|
||||
|
||||
final commentsCubit = MockConsultantCommentsCubit();
|
||||
when(commentsCubit.state).thenReturn(
|
||||
ConsultantCommentsState(
|
||||
commentsModel: ConsultantCommentModel(comment: ''),
|
||||
),
|
||||
);
|
||||
when(
|
||||
commentsCubit.stream,
|
||||
).thenAnswer((_) => const Stream<ConsultantCommentsState>.empty());
|
||||
when(commentsCubit.close()).thenAnswer((_) async => Future<void>.value());
|
||||
|
||||
sl.registerFactory<DiaryCubit>(() => diaryCubit);
|
||||
sl.registerFactory<ConsultantCommentsCubit>(() => commentsCubit);
|
||||
|
||||
await tester.pumpWidget(wrap(DiaryScreen(date: yesterday)));
|
||||
await tester.pump(const Duration(milliseconds: 200));
|
||||
|
||||
expect(find.text('Добавить'), findsOneWidget);
|
||||
expect(find.text('Закончить день'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('hides add button for a commented day', (tester) async {
|
||||
final yesterday = DateTime.now().subtract(const Duration(days: 1));
|
||||
final diaryCubit = MockDiaryCubit();
|
||||
when(diaryCubit.state).thenReturn(
|
||||
loadedState(
|
||||
hasChosenPlan: false,
|
||||
date: _calendarDate(yesterday),
|
||||
isDiaryCommented: true,
|
||||
),
|
||||
);
|
||||
when(
|
||||
diaryCubit.stream,
|
||||
).thenAnswer((_) => const Stream<DiaryState>.empty());
|
||||
when(diaryCubit.close()).thenAnswer((_) async => Future<void>.value());
|
||||
when(
|
||||
diaryCubit.fetchDiaryV2(
|
||||
needLoading: anyNamed('needLoading'),
|
||||
diaryId: anyNamed('diaryId'),
|
||||
),
|
||||
).thenAnswer((_) async => true);
|
||||
|
||||
final commentsCubit = MockConsultantCommentsCubit();
|
||||
when(commentsCubit.state).thenReturn(
|
||||
ConsultantCommentsState(
|
||||
commentsModel: ConsultantCommentModel(comment: ''),
|
||||
),
|
||||
);
|
||||
when(
|
||||
commentsCubit.stream,
|
||||
).thenAnswer((_) => const Stream<ConsultantCommentsState>.empty());
|
||||
when(commentsCubit.close()).thenAnswer((_) async => Future<void>.value());
|
||||
|
||||
sl.registerFactory<DiaryCubit>(() => diaryCubit);
|
||||
sl.registerFactory<ConsultantCommentsCubit>(() => commentsCubit);
|
||||
|
||||
await tester.pumpWidget(wrap(DiaryScreen(date: yesterday)));
|
||||
await tester.pump(const Duration(milliseconds: 200));
|
||||
|
||||
expect(find.text('Добавить'), findsNothing);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
String _calendarDate(DateTime date) {
|
||||
final month = date.month.toString().padLeft(2, '0');
|
||||
final day = date.day.toString().padLeft(2, '0');
|
||||
return '${date.year}-$month-$day';
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
|
||||
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
||||
import 'package:eatvio_app/features/consultations/models/consultation_model.dart';
|
||||
import 'package:eatvio_app/features/diary/entities/diary_day_entity.dart';
|
||||
@@ -27,7 +26,8 @@ class FakeDiaryCubit extends Cubit<DiaryState> implements DiaryCubit {
|
||||
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
||||
}
|
||||
|
||||
class FakeConsultantCommentsCubit extends Cubit<ConsultantCommentsState> implements ConsultantCommentsCubit {
|
||||
class FakeConsultantCommentsCubit extends Cubit<ConsultantCommentsState>
|
||||
implements ConsultantCommentsCubit {
|
||||
FakeConsultantCommentsCubit(super.initialState);
|
||||
@override
|
||||
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
||||
@@ -60,9 +60,16 @@ void main() {
|
||||
</svg>
|
||||
''';
|
||||
|
||||
final bundle = _FakeAssetBundle({'assets/icons/revert_product.svg': fakeSvg, 'assets/icons/edit.svg': fakeSvg});
|
||||
final bundle = _FakeAssetBundle({
|
||||
'assets/icons/revert_product.svg': fakeSvg,
|
||||
'assets/icons/edit.svg': fakeSvg,
|
||||
});
|
||||
|
||||
Widget wrap({required DiaryCubit diaryCubit, required ConsultantCommentsCubit consultantCommentsCubit, required Widget child}) {
|
||||
Widget wrap({
|
||||
required DiaryCubit diaryCubit,
|
||||
required ConsultantCommentsCubit consultantCommentsCubit,
|
||||
required Widget child,
|
||||
}) {
|
||||
return DefaultAssetBundle(
|
||||
bundle: bundle,
|
||||
child: MaterialApp(
|
||||
@@ -77,7 +84,9 @@ void main() {
|
||||
home: MultiBlocProvider(
|
||||
providers: [
|
||||
BlocProvider<DiaryCubit>.value(value: diaryCubit),
|
||||
BlocProvider<ConsultantCommentsCubit>.value(value: consultantCommentsCubit),
|
||||
BlocProvider<ConsultantCommentsCubit>.value(
|
||||
value: consultantCommentsCubit,
|
||||
),
|
||||
],
|
||||
child: Scaffold(body: child),
|
||||
),
|
||||
@@ -85,17 +94,36 @@ void main() {
|
||||
);
|
||||
}
|
||||
|
||||
DiaryStateLoaded loadedState({required PortionModel portion, required bool consultantView}) {
|
||||
DiaryStateLoaded loadedState({
|
||||
required PortionModel portion,
|
||||
required bool consultantView,
|
||||
String date = '2025-01-01',
|
||||
bool isDiaryFinished = false,
|
||||
bool isDiaryCommented = false,
|
||||
int closed = 0,
|
||||
}) {
|
||||
final diary = DiaryDayEntity(
|
||||
id: 1,
|
||||
date: '2025-01-01',
|
||||
date: date,
|
||||
wakeup: '07:00',
|
||||
retire: '23:00',
|
||||
closed: 0,
|
||||
closed: closed,
|
||||
comment: '',
|
||||
typeOfDiary: const TypeOfDiaryModel(hasChosenDiet: false, hasChosenPlan: true, diaryState: DiaryStateEnum.started),
|
||||
typeOfDiary: const TypeOfDiaryModel(
|
||||
hasChosenDiet: false,
|
||||
hasChosenPlan: true,
|
||||
diaryState: DiaryStateEnum.started,
|
||||
),
|
||||
meals: [
|
||||
MealModel(id: 1, time: '08:00', typeNumber: 1, satiety: 0, mood: 0, portions: [portion], photos: const []),
|
||||
MealModel(
|
||||
id: 1,
|
||||
time: '08:00',
|
||||
typeNumber: 1,
|
||||
satiety: 0,
|
||||
mood: 0,
|
||||
portions: [portion],
|
||||
photos: const [],
|
||||
),
|
||||
],
|
||||
chosenPlanId: 1,
|
||||
dayOfChosenDiet: null,
|
||||
@@ -103,8 +131,18 @@ void main() {
|
||||
);
|
||||
|
||||
const nutrition = NutritionDataModel(
|
||||
dailyNutritionIntake: NutritionModel(energy: 0, proteins: 0, fats: 0, carbohydrates: 0),
|
||||
dailyNutritionRecommendation: NutritionModel(energy: 0, proteins: 0, fats: 0, carbohydrates: 0),
|
||||
dailyNutritionIntake: NutritionModel(
|
||||
energy: 0,
|
||||
proteins: 0,
|
||||
fats: 0,
|
||||
carbohydrates: 0,
|
||||
),
|
||||
dailyNutritionRecommendation: NutritionModel(
|
||||
energy: 0,
|
||||
proteins: 0,
|
||||
fats: 0,
|
||||
carbohydrates: 0,
|
||||
),
|
||||
);
|
||||
|
||||
return DiaryState.loaded(
|
||||
@@ -113,13 +151,17 @@ void main() {
|
||||
selectedDay: DateTime(2025, 1, 1),
|
||||
selectedMeal: 1,
|
||||
isConsultantView: consultantView,
|
||||
isDiaryFinished: false,
|
||||
isDiaryCommented: false,
|
||||
isDiaryFinished: isDiaryFinished,
|
||||
isDiaryCommented: isDiaryCommented,
|
||||
consultantReview: ConsultantReviewModel(
|
||||
planName: 'Plan',
|
||||
planDuration: 10,
|
||||
currentPlanDay: 1,
|
||||
consultantComments: ConsultantComments(dayComment: null, mealsComments: const [], portionsComments: const []),
|
||||
consultantComments: ConsultantComments(
|
||||
dayComment: null,
|
||||
mealsComments: const [],
|
||||
portionsComments: const [],
|
||||
),
|
||||
),
|
||||
)
|
||||
as DiaryStateLoaded;
|
||||
@@ -140,9 +182,21 @@ void main() {
|
||||
DiaryState.loadedTomorrowWithDiet(
|
||||
diary: const DiaryFutureDietDayEntity(
|
||||
date: '2025-01-02',
|
||||
typeOfDiary: TypeOfDiaryModel(hasChosenDiet: true, hasChosenPlan: false, diaryState: null),
|
||||
typeOfDiary: TypeOfDiaryModel(
|
||||
hasChosenDiet: true,
|
||||
hasChosenPlan: false,
|
||||
diaryState: null,
|
||||
),
|
||||
meals: [
|
||||
MealModel(id: 1, time: '08:00', typeNumber: 1, satiety: 0, mood: 0, portions: [portion], photos: []),
|
||||
MealModel(
|
||||
id: 1,
|
||||
time: '08:00',
|
||||
typeNumber: 1,
|
||||
satiety: 0,
|
||||
mood: 0,
|
||||
portions: [portion],
|
||||
photos: [],
|
||||
),
|
||||
],
|
||||
diet: null,
|
||||
dayOfChosenDiet: 1,
|
||||
@@ -151,7 +205,11 @@ void main() {
|
||||
selectedMeal: 1,
|
||||
),
|
||||
);
|
||||
final commentsCubit = FakeConsultantCommentsCubit(ConsultantCommentsState(commentsModel: ConsultantCommentModel(comment: '')));
|
||||
final commentsCubit = FakeConsultantCommentsCubit(
|
||||
ConsultantCommentsState(
|
||||
commentsModel: ConsultantCommentModel(comment: ''),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pumpWidget(
|
||||
wrap(
|
||||
@@ -177,8 +235,14 @@ void main() {
|
||||
fromDiet: false,
|
||||
portionState: PortionState.eatenDeleted,
|
||||
);
|
||||
final diaryCubit = FakeDiaryCubit(loadedState(portion: portion, consultantView: false));
|
||||
final commentsCubit = FakeConsultantCommentsCubit(ConsultantCommentsState(commentsModel: ConsultantCommentModel(comment: '')));
|
||||
final diaryCubit = FakeDiaryCubit(
|
||||
loadedState(portion: portion, consultantView: false),
|
||||
);
|
||||
final commentsCubit = FakeConsultantCommentsCubit(
|
||||
ConsultantCommentsState(
|
||||
commentsModel: ConsultantCommentModel(comment: ''),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pumpWidget(
|
||||
wrap(
|
||||
@@ -194,32 +258,89 @@ void main() {
|
||||
expect(find.byType(ProductWidget), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('consultant view toggles comment widget and shows existing comment container', (tester) async {
|
||||
testWidgets(
|
||||
'consultant view toggles comment widget and shows existing comment container',
|
||||
(tester) async {
|
||||
const portion = PortionModel(
|
||||
id: 1,
|
||||
productNutritionalValueId: null,
|
||||
productCaloriesInfo: null,
|
||||
productName: 'Portion',
|
||||
quantity: 100,
|
||||
portion: 'gr',
|
||||
fromDiet: false,
|
||||
portionState: PortionState.filledDiet,
|
||||
);
|
||||
final state = loadedState(portion: portion, consultantView: true)
|
||||
.copyWith(
|
||||
consultantReview: ConsultantReviewModel(
|
||||
planName: 'Plan',
|
||||
planDuration: 10,
|
||||
currentPlanDay: 1,
|
||||
consultantComments: ConsultantComments(
|
||||
dayComment: null,
|
||||
mealsComments: const [],
|
||||
portionsComments: [
|
||||
PortionComment(portionId: 1, comment: 'Looks good'),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
final diaryCubit = FakeDiaryCubit(state);
|
||||
final commentsCubit = FakeConsultantCommentsCubit(
|
||||
ConsultantCommentsState(
|
||||
commentsModel: ConsultantCommentModel(comment: ''),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pumpWidget(
|
||||
wrap(
|
||||
diaryCubit: diaryCubit,
|
||||
consultantCommentsCubit: commentsCubit,
|
||||
child: const ProductWidget(portion: portion),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
expect(find.byType(CommentContainer), findsOneWidget);
|
||||
expect(find.text('Looks good'), findsOneWidget);
|
||||
|
||||
// Tap edit icon to enable PortionCommentWidget.
|
||||
await tester.tap(find.byType(GestureDetector).first);
|
||||
await tester.pump();
|
||||
|
||||
expect(find.byType(PortionCommentWidget), findsOneWidget);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('can edit a portion on a finished day inside the edit window', (
|
||||
tester,
|
||||
) async {
|
||||
const portion = PortionModel(
|
||||
id: 1,
|
||||
productNutritionalValueId: null,
|
||||
productCaloriesInfo: null,
|
||||
productName: 'Portion',
|
||||
productName: 'Apple',
|
||||
quantity: 100,
|
||||
portion: 'gr',
|
||||
fromDiet: false,
|
||||
portionState: PortionState.filledDiet,
|
||||
portionState: PortionState.eaten,
|
||||
);
|
||||
final state = loadedState(portion: portion, consultantView: true).copyWith(
|
||||
consultantReview: ConsultantReviewModel(
|
||||
planName: 'Plan',
|
||||
planDuration: 10,
|
||||
currentPlanDay: 1,
|
||||
consultantComments: ConsultantComments(
|
||||
dayComment: null,
|
||||
mealsComments: const [],
|
||||
portionsComments: [PortionComment(portionId: 1, comment: 'Looks good')],
|
||||
),
|
||||
final yesterday = DateTime.now().subtract(const Duration(days: 1));
|
||||
final diaryCubit = FakeDiaryCubit(
|
||||
loadedState(
|
||||
portion: portion,
|
||||
consultantView: false,
|
||||
date: _calendarDate(yesterday),
|
||||
isDiaryFinished: true,
|
||||
),
|
||||
);
|
||||
final commentsCubit = FakeConsultantCommentsCubit(
|
||||
ConsultantCommentsState(
|
||||
commentsModel: ConsultantCommentModel(comment: ''),
|
||||
),
|
||||
);
|
||||
|
||||
final diaryCubit = FakeDiaryCubit(state);
|
||||
final commentsCubit = FakeConsultantCommentsCubit(ConsultantCommentsState(commentsModel: ConsultantCommentModel(comment: '')));
|
||||
|
||||
await tester.pumpWidget(
|
||||
wrap(
|
||||
@@ -230,15 +351,51 @@ void main() {
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
expect(tester.widget<ListTile>(find.byType(ListTile)).onTap, isNotNull);
|
||||
});
|
||||
|
||||
expect(find.byType(CommentContainer), findsOneWidget);
|
||||
expect(find.text('Looks good'), findsOneWidget);
|
||||
testWidgets('cannot edit a portion on a commented day', (tester) async {
|
||||
const portion = PortionModel(
|
||||
id: 1,
|
||||
productNutritionalValueId: null,
|
||||
productCaloriesInfo: null,
|
||||
productName: 'Apple',
|
||||
quantity: 100,
|
||||
portion: 'gr',
|
||||
fromDiet: false,
|
||||
portionState: PortionState.eaten,
|
||||
);
|
||||
final yesterday = DateTime.now().subtract(const Duration(days: 1));
|
||||
final diaryCubit = FakeDiaryCubit(
|
||||
loadedState(
|
||||
portion: portion,
|
||||
consultantView: false,
|
||||
date: _calendarDate(yesterday),
|
||||
isDiaryCommented: true,
|
||||
),
|
||||
);
|
||||
final commentsCubit = FakeConsultantCommentsCubit(
|
||||
ConsultantCommentsState(
|
||||
commentsModel: ConsultantCommentModel(comment: ''),
|
||||
),
|
||||
);
|
||||
|
||||
// Tap edit icon to enable PortionCommentWidget.
|
||||
await tester.tap(find.byType(GestureDetector).first);
|
||||
await tester.pumpWidget(
|
||||
wrap(
|
||||
diaryCubit: diaryCubit,
|
||||
consultantCommentsCubit: commentsCubit,
|
||||
child: const ProductWidget(portion: portion),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
expect(find.byType(PortionCommentWidget), findsOneWidget);
|
||||
expect(tester.widget<ListTile>(find.byType(ListTile)).onTap, isNull);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
String _calendarDate(DateTime date) {
|
||||
final month = date.month.toString().padLeft(2, '0');
|
||||
final day = date.day.toString().padLeft(2, '0');
|
||||
return '${date.year}-$month-$day';
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ void main() {
|
||||
);
|
||||
const state = NotificationSettingsStateLoaded(notificationSettings: settings);
|
||||
|
||||
expect(state.props, [settings]);
|
||||
expect(state.props, [settings, null, null]);
|
||||
expect(state.notificationSettings, settings);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:eatvio_app/features/menu/presentation/bloc/notification_settings_cubit/notification_settings_cubit.dart';
|
||||
import 'package:eatvio_app/features/menu/presentation/bloc/notification_settings_cubit/notification_settings_state.dart';
|
||||
import 'package:eatvio_app/features/menu/repositories/notification_settings_repository.dart';
|
||||
@@ -103,6 +104,51 @@ void main() {
|
||||
final errorState = notificationSettingsCubit!.state as NotificationSettingsStateError;
|
||||
expect(errorState.errorMessage, contains('Ошибка обновления'));
|
||||
});
|
||||
|
||||
test('updateNotificationSettings - validation error keeps the screen and exposes an alert', () async {
|
||||
notificationSettingsCubit = NotificationSettingsCubit(
|
||||
notificationSettingsRepository: mockNotificationSettingsRepository,
|
||||
);
|
||||
when(mockNotificationSettingsRepository.getNotificationSettings()).thenAnswer((_) async => MenuFixtures.notificationSettingsModel());
|
||||
await notificationSettingsCubit!.getNotificationSettings();
|
||||
|
||||
const message = 'Между приемами пищи должна быть разница минимум час';
|
||||
when(mockNotificationSettingsRepository.updateNotificationSettings(any)).thenThrow(
|
||||
DioException(
|
||||
requestOptions: RequestOptions(path: '/api/profile/update_notification_settings'),
|
||||
type: DioExceptionType.badResponse,
|
||||
response: Response(
|
||||
requestOptions: RequestOptions(path: '/api/profile/update_notification_settings'),
|
||||
statusCode: 422,
|
||||
data: {
|
||||
'message': message,
|
||||
'errors': {
|
||||
'diary_reminder_notifications.second_reminder': [message],
|
||||
},
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await notificationSettingsCubit!.updateNotificationSettings({
|
||||
'diary_reminder_notifications': {
|
||||
'second_reminder': {'enabled': true, 'local_time_hours': 9, 'local_time_mins': 30},
|
||||
},
|
||||
});
|
||||
|
||||
expect(notificationSettingsCubit!.state, isA<NotificationSettingsStateLoaded>());
|
||||
final loadedState = notificationSettingsCubit!.state as NotificationSettingsStateLoaded;
|
||||
expect(loadedState.alertMessage, message);
|
||||
expect(loadedState.notificationSettings.newUpdatesNotification, true);
|
||||
|
||||
when(mockNotificationSettingsRepository.updateNotificationSettings(any)).thenThrow(Exception('сеть'));
|
||||
await notificationSettingsCubit!.updateNotificationSettings({'new_updates_notification': false});
|
||||
|
||||
final retried = notificationSettingsCubit!.state as NotificationSettingsStateLoaded;
|
||||
expect(retried.alertMessage, 'Не удалось сохранить настройки уведомлений');
|
||||
expect(retried.alertId, isNot(loadedState.alertId));
|
||||
expect(retried.notificationSettings, loadedState.notificationSettings);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:eatvio_app/features/menu/presentation/notification_settings_user_message.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
group('notificationSettingsUpdateAlertMessage', () {
|
||||
test('uses the Laravel validation message for a meal reminder gap', () {
|
||||
const message = 'Между приемами пищи должна быть разница минимум час';
|
||||
final error = DioException(
|
||||
requestOptions: RequestOptions(
|
||||
path: '/api/profile/update_notification_settings',
|
||||
),
|
||||
type: DioExceptionType.badResponse,
|
||||
response: Response(
|
||||
requestOptions: RequestOptions(
|
||||
path: '/api/profile/update_notification_settings',
|
||||
),
|
||||
statusCode: 422,
|
||||
data: {
|
||||
'message': message,
|
||||
'errors': {
|
||||
'diary_reminder_notifications.second_reminder': [message],
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
expect(notificationSettingsUpdateAlertMessage(error), message);
|
||||
});
|
||||
|
||||
test('uses a short message for other failures', () {
|
||||
expect(
|
||||
notificationSettingsUpdateAlertMessage(Exception('stack')),
|
||||
notificationSettingsSaveFailedMessage,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:eatvio_app/core/app_config.dart';
|
||||
import 'package:eatvio_app/core/app_initializer.dart';
|
||||
import 'package:eatvio_app/core/locator_service.dart';
|
||||
@@ -7,6 +8,7 @@ import 'package:eatvio_app/features/menu/presentation/bloc/notification_settings
|
||||
import 'package:eatvio_app/features/menu/presentation/pages/notifications_settings_screen.dart';
|
||||
import 'package:eatvio_app/features/menu/repositories/notification_settings_repository.dart';
|
||||
import 'package:eatvio_app/generated/l10n.dart';
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
||||
import 'package:flutter_localizations/flutter_localizations.dart';
|
||||
@@ -111,5 +113,55 @@ void main() {
|
||||
expect(find.textContaining('Ошибка'), findsOneWidget);
|
||||
expect(find.text('Повторить'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('shows an alert instead of replacing the screen when reminder times are too close', (tester) async {
|
||||
tester.view.physicalSize = const Size(800, 1600);
|
||||
tester.view.devicePixelRatio = 1.0;
|
||||
addTearDown(() => tester.view.resetPhysicalSize());
|
||||
|
||||
const message = 'Между приемами пищи должна быть разница минимум час';
|
||||
final repository = MockNotificationSettingsRepository();
|
||||
final cubit = NotificationSettingsCubit(notificationSettingsRepository: repository);
|
||||
|
||||
when(repository.getNotificationSettings()).thenAnswer((_) async => mockNotificationSettings);
|
||||
when(repository.updateNotificationSettings(any)).thenThrow(
|
||||
DioException(
|
||||
requestOptions: RequestOptions(path: '/api/profile/update_notification_settings'),
|
||||
type: DioExceptionType.badResponse,
|
||||
response: Response(
|
||||
requestOptions: RequestOptions(path: '/api/profile/update_notification_settings'),
|
||||
statusCode: 422,
|
||||
data: {
|
||||
'message': message,
|
||||
'errors': {
|
||||
'diary_reminder_notifications.second_reminder': [message],
|
||||
},
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
sl.registerLazySingleton<NotificationSettingsRepository>(() => repository);
|
||||
sl.registerLazySingleton<NotificationSettingsCubit>(() => cubit);
|
||||
|
||||
await tester.pumpWidget(wrap(const NotificationsSettingsScreen()));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
|
||||
expect(find.text('Новые обновления'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.byType(CupertinoSwitch).first);
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
|
||||
expect(find.text(message), findsOneWidget);
|
||||
expect(find.text('Новые обновления'), findsOneWidget);
|
||||
expect(find.text('Повторить'), findsNothing);
|
||||
|
||||
await tester.tap(find.text('OK'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Новые обновления'), findsOneWidget);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -47,5 +47,10 @@ class _NoopCreatePlanService implements CreatePlanService {
|
||||
Future<void> createPlan({required FormData formData}) {
|
||||
throw UnimplementedError('Not used in fixture tests');
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> updatePublishedPlan({required String slug, required FormData formData}) {
|
||||
throw UnimplementedError('Not used in fixture tests');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user