From dcd343e765f9171b03993ea7575ea9571b8eedc4 Mon Sep 17 00:00:00 2001 From: misha Date: Wed, 14 Oct 2026 08:16:20 +0300 Subject: [PATCH] feat(push_notifications): issue-660, fix: issue-908 --- .../kotlin/com/eatvioapp/app/MainActivity.kt | 43 +- .../error_reporting/error_report_policy.dart | 2 + lib/core/firebase_api.dart | 100 ++- lib/core/push/hms_client_error.dart | 36 + lib/core/utils/url_utils.dart | 11 + .../presentation/pages/blog_post_screen.dart | 291 +++++-- .../pages/blog_post_create_screen.dart | 319 +++---- .../widgets/bottom_navigation_bar_widget.dart | 174 +++- .../presentation/widgets/author_card.dart | 101 ++- .../presentation/widgets/comments_widget.dart | 113 ++- .../widgets/consultant_widget.dart | 77 +- .../presentation/widgets/plan_widget.dart | 130 ++- .../notification_settings_cubit.dart | 13 + .../notification_settings_state.dart | 10 +- .../notification_settings_user_message.dart | 42 + .../pages/notifications_settings_screen.dart | 32 +- .../widgets/blocked_user_widget.dart | 24 +- .../presentation/consultation_widget.dart | 189 ++-- .../presentation/pages/profile_screen.dart | 806 ++++++++++++++---- .../presentation/widgets/sub_widget.dart | 24 +- pubspec.yaml | 2 +- .../error_report_policy_test.dart | 26 + test/core/push/hms_client_error_test.dart | 112 +++ test/core/utils/url_utils_test.dart | 26 + .../pages/blog_post_create_screen_test.dart | 48 +- .../notification_settings_state_test.dart | 2 +- .../notification_settings_cubit_test.dart | 46 + ...tification_settings_user_message_test.dart | 38 + ...ifications_settings_screen_smoke_test.dart | 52 ++ 29 files changed, 2281 insertions(+), 608 deletions(-) create mode 100644 lib/core/push/hms_client_error.dart create mode 100644 lib/core/utils/url_utils.dart create mode 100644 lib/features/menu/presentation/notification_settings_user_message.dart create mode 100644 test/core/push/hms_client_error_test.dart create mode 100644 test/core/utils/url_utils_test.dart create mode 100644 test/features/menu/presentation/notification_settings_user_message_test.dart diff --git a/android/app/src/main/kotlin/com/eatvioapp/app/MainActivity.kt b/android/app/src/main/kotlin/com/eatvioapp/app/MainActivity.kt index 1ae3354f..cb16be41 100644 --- a/android/app/src/main/kotlin/com/eatvioapp/app/MainActivity.kt +++ b/android/app/src/main/kotlin/com/eatvioapp/app/MainActivity.kt @@ -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) + } } } } diff --git a/lib/core/error_reporting/error_report_policy.dart b/lib/core/error_reporting/error_report_policy.dart index b7ff92ec..e2864af7 100644 --- a/lib/core/error_reporting/error_report_policy.dart +++ b/lib/core/error_reporting/error_report_policy.dart @@ -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); diff --git a/lib/core/firebase_api.dart b/lib/core/firebase_api.dart index d4f702f0..cf4496de 100644 --- a/lib/core/firebase_api.dart +++ b/lib/core/firebase_api.dart @@ -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 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 _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().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().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 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().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 _getFcmToken() { + return _firebaseMessaging.getToken().timeout( + const Duration(seconds: 5), + onTimeout: () => null, + ); + } + Future _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 _isHmsAvailable() async { + try { + const channel = MethodChannel('com.eatvioapp/hms_push'); + final available = await channel.invokeMethod('isHmsAvailable').timeout(const Duration(seconds: 5)); + return available ?? false; + } catch (e, st) { + log('HMS availability check failed: $e', stackTrace: st); + return false; + } + } + Future _getHuaweiPushTokenNative() async { try { const channel = MethodChannel('com.eatvioapp/hms_push'); final String? token = await channel.invokeMethod('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; diff --git a/lib/core/push/hms_client_error.dart b/lib/core/push/hms_client_error.dart new file mode 100644 index 00000000..bf5a5fb2 --- /dev/null +++ b/lib/core/push/hms_client_error.dart @@ -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'; +} diff --git a/lib/core/utils/url_utils.dart b/lib/core/utils/url_utils.dart new file mode 100644 index 00000000..dd5d53b5 --- /dev/null +++ b/lib/core/utils/url_utils.dart @@ -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'; +} diff --git a/lib/features/blog_post/presentation/pages/blog_post_screen.dart b/lib/features/blog_post/presentation/pages/blog_post_screen.dart index 0c235eb6..3578edb6 100644 --- a/lib/features/blog_post/presentation/pages/blog_post_screen.dart +++ b/lib/features/blog_post/presentation/pages/blog_post_screen.dart @@ -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 createState() => _BlogPostScreenState(); @@ -45,7 +50,10 @@ class _BlogPostScreenState extends State { } void _openGallery(List 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 { return BlocProvider( create: (context) => sl()..getBlogPost(widget.id), child: BlocProvider( - create: (BuildContext context) => commentsCubit..getComments(widget.id.toString(), CommentSourceType.blogPost, true), + create: (BuildContext context) => commentsCubit + ..getComments(widget.id.toString(), CommentSourceType.blogPost, true), child: BlocBuilder( builder: (context, blogPostState) { return BlocConsumer( @@ -81,15 +90,23 @@ class _BlogPostScreenState extends State { 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 { 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 { 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 { 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,10 @@ class _BlogPostScreenState extends State { ); if (!context.mounted) return; - if (isSent) context.read().incrementCommentsState(widget.id); + if (isSent) + context + .read() + .incrementCommentsState(widget.id); context.read().setReplyId(null); setState(() { @@ -174,10 +224,19 @@ class _BlogPostScreenState extends State { } }, 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 +256,36 @@ class _BlogPostScreenState extends State { ), 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 +294,48 @@ class _BlogPostScreenState extends State { children: [ CircleAvatar( radius: 16, - foregroundImage: NetworkImage(sl().baseUrl + (blogPostState.blogPostModel?.author.authorProfileIcon ?? '')), - backgroundImage: const AssetImage('assets/images/no_avatar.png'), + foregroundImage: NetworkImage( + joinBaseUrl( + sl().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 +343,20 @@ class _BlogPostScreenState extends State { ), ), ), - 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 +365,31 @@ class _BlogPostScreenState extends State { 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 +399,43 @@ class _BlogPostScreenState extends State { }, ), 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().toggleLike(); + await context + .read() + .toggleLike(); // ignore: use_build_context_synchronously - final updated = context.read().state.blogPostModel; + final updated = context + .read() + .state + .blogPostModel; if (updated != null) { // ignore: use_build_context_synchronously - context.read().updateFeedBlogPostList(updated, updated.wasLikedByMe); + context + .read() + .updateFeedBlogPostList( + updated, + updated.wasLikedByMe, + ); } }, ), @@ -299,21 +443,36 @@ class _BlogPostScreenState extends State { _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().baseUrl}/ru/blog_post/${widget.id}'), - sharePositionOrigin: box!.localToGlobal(Offset.zero) & box.size, + uri: Uri.parse( + '${sl().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 +480,10 @@ class _BlogPostScreenState extends State { 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 +491,11 @@ class _BlogPostScreenState extends State { 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 +520,23 @@ class _BlogPostScreenState extends State { ); } - 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 +546,10 @@ class _BlogPostScreenState extends State { if (text != null) Text( text, - style: TextStyle(color: textColor ?? appTheme.gray600, fontWeight: FontWeight.w500), + style: TextStyle( + color: textColor ?? appTheme.gray600, + fontWeight: FontWeight.w500, + ), ), ], ), diff --git a/lib/features/blog_post_create/presentation/pages/blog_post_create_screen.dart b/lib/features/blog_post_create/presentation/pages/blog_post_create_screen.dart index d06fc227..b0099417 100644 --- a/lib/features/blog_post_create/presentation/pages/blog_post_create_screen.dart +++ b/lib/features/blog_post_create/presentation/pages/blog_post_create_screen.dart @@ -674,163 +674,185 @@ class _BlogPostCreateScreenState extends State { ); } - 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( 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 { 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), diff --git a/lib/features/bottom_navigation_bar/presentation/widgets/bottom_navigation_bar_widget.dart b/lib/features/bottom_navigation_bar/presentation/widgets/bottom_navigation_bar_widget.dart index 207b348a..c1f54a24 100644 --- a/lib/features/bottom_navigation_bar/presentation/widgets/bottom_navigation_bar_widget.dart +++ b/lib/features/bottom_navigation_bar/presentation/widgets/bottom_navigation_bar_widget.dart @@ -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 createState() => _BottomNavigationBarWidgetState(); + State createState() => + _BottomNavigationBarWidgetState(); } class _BottomNavigationBarWidgetState extends State { @@ -40,9 +43,24 @@ class _BottomNavigationBarWidgetState extends State { List _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 { 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 { 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 { 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,14 @@ class _BottomNavigationBarWidgetState extends State { // Подсветка таба по текущему маршруту (совпадает при навигации через 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 +215,14 @@ class _BottomNavigationBarWidgetState extends State { label: '', icon: CircleAvatar( foregroundImage: NetworkImage( - profileModel.profilePhotoUrl.contains('https') ? profileModel.profilePhotoUrl : sl().baseUrl + profileModel.profilePhotoUrl, + joinBaseUrl( + sl().baseUrl, + profileModel.profilePhotoUrl, + ), + ), + backgroundImage: const AssetImage( + 'assets/images/no_avatar.png', ), - backgroundImage: const AssetImage('assets/images/no_avatar.png'), radius: 16, ), ), @@ -186,22 +230,54 @@ class _BottomNavigationBarWidgetState extends State { 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 +286,10 @@ class _BottomNavigationBarWidgetState extends State { 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 +301,10 @@ class _BottomNavigationBarWidgetState extends State { 'assets/icons/consultations.svg', width: 20, height: 20, - colorFilter: ColorFilter.mode(appTheme.blueA400, BlendMode.srcIn), + colorFilter: ColorFilter.mode( + appTheme.blueA400, + BlendMode.srcIn, + ), ), ), ), @@ -230,14 +312,26 @@ class _BottomNavigationBarWidgetState extends State { label: s.chat, icon: BlocBuilder( 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 +339,16 @@ class _BottomNavigationBarWidgetState extends State { 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 +362,28 @@ class _BottomNavigationBarWidgetState extends State { 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, ), diff --git a/lib/features/diets/presentation/widgets/author_card.dart b/lib/features/diets/presentation/widgets/author_card.dart index 3a7c17b8..69a92966 100644 --- a/lib/features/diets/presentation/widgets/author_card.dart +++ b/lib/features/diets/presentation/widgets/author_card.dart @@ -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().baseUrl + customerProfile!.profilePhotoUrl - : author.authorProfileIcon.contains('https') - ? author.authorProfileIcon - : sl().baseUrl + author.authorProfileIcon, + joinBaseUrl( + sl().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, + ), + ), ), ), ], diff --git a/lib/features/feed/presentation/widgets/comments_widget.dart b/lib/features/feed/presentation/widgets/comments_widget.dart index 61ba39d2..e1383e9a 100644 --- a/lib/features/feed/presentation/widgets/comments_widget.dart +++ b/lib/features/feed/presentation/widgets/comments_widget.dart @@ -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 { @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 { 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 { ); } - 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().state as ProfileAvatarStateLoaded).profile.slug == comment.authorSlug; + final isMe = + (sl().state as ProfileAvatarStateLoaded) + .profile + .slug == + comment.authorSlug; return GestureDetector( key: key, onTap: () { @@ -113,7 +138,10 @@ class _CommentsWidgetState extends State { showInfoToast(s.text_was_copied); }, edit: (String text) { - context.read().updateComment(id: comment.id, body: text); + context.read().updateComment( + id: comment.id, + body: text, + ); }, delete: () { context.read().deleteComment(id: comment.id); @@ -122,7 +150,10 @@ class _CommentsWidgetState extends State { 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 { 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 { 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().baseUrl + comment.authorProfileIcon), - backgroundImage: const AssetImage('assets/images/no_avatar.png'), + foregroundImage: NetworkImage( + joinBaseUrl( + sl().baseUrl, + comment.authorProfileIcon, + ), + ), + backgroundImage: const AssetImage( + 'assets/images/no_avatar.png', + ), radius: radius, ), ), @@ -159,14 +200,26 @@ class _CommentsWidgetState extends State { 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 { 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().setReplyId(comment.id); + context.read().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, + ), ), ), ], diff --git a/lib/features/feed/presentation/widgets/consultant_widget.dart b/lib/features/feed/presentation/widgets/consultant_widget.dart index cec6d24a..686b1244 100644 --- a/lib/features/feed/presentation/widgets/consultant_widget.dart +++ b/lib/features/feed/presentation/widgets/consultant_widget.dart @@ -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().baseUrl + consultant.photo), - backgroundImage: const AssetImage('assets/images/no_avatar.png'), + CircleAvatar( + foregroundImage: NetworkImage( + joinBaseUrl(sl().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().baseUrl}/storage/${badges[i].image}', width: 28, height: 28) - : CachedNetworkImage(imageUrl: '${sl().baseUrl}/storage/${badges[i].image}', width: 28, height: 28), - i < badges.length - 1 ? const SizedBox(width: 8) : const SizedBox.shrink(), + ? NetworkSvgPicture( + url: + '${sl().baseUrl}/storage/${badges[i].image}', + width: 28, + height: 28, + ) + : CachedNetworkImage( + imageUrl: + '${sl().baseUrl}/storage/${badges[i].image}', + width: 28, + height: 28, + ), + i < badges.length - 1 + ? const SizedBox(width: 8) + : const SizedBox.shrink(), ], ], ), diff --git a/lib/features/feed/presentation/widgets/plan_widget.dart b/lib/features/feed/presentation/widgets/plan_widget.dart index f979e73a..13f57367 100644 --- a/lib/features/feed/presentation/widgets/plan_widget.dart +++ b/lib/features/feed/presentation/widgets/plan_widget.dart @@ -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 { 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 { Stack( children: [ ClipRRect( - borderRadius: const BorderRadius.vertical(top: Radius.circular(12)), + borderRadius: const BorderRadius.vertical( + top: Radius.circular(12), + ), child: CachedNetworkImage( - imageUrl: '${sl().baseUrl}/storage/${widget.plan.image}', + imageUrl: + '${sl().baseUrl}/storage/${widget.plan.image}', fit: BoxFit.cover, width: MediaQuery.of(context).size.width, height: 426, @@ -61,14 +72,22 @@ class _PlanWidgetState extends State { child: BookmarkButton( onTap: () async { if (widget.plan.isFavorite) { - await context.read().removePlanFromFavorite(widget.plan.slug, widget.index); + await context.read().removePlanFromFavorite( + widget.plan.slug, + widget.index, + ); } else { - await context.read().addPlanToFavorite(widget.plan.slug, widget.index); + await context.read().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, @@ -100,7 +119,12 @@ class _PlanWidgetState extends State { 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, + ), ), ), ], @@ -111,14 +135,22 @@ class _PlanWidgetState extends State { 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, ), ), @@ -127,7 +159,12 @@ class _PlanWidgetState extends State { 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, + ), ), ), ], @@ -136,7 +173,10 @@ class _PlanWidgetState extends State { 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), @@ -147,7 +187,15 @@ class _PlanWidgetState extends State { height: 46, decoration: BoxDecoration( shape: BoxShape.circle, - image: DecorationImage(image: NetworkImage(sl().baseUrl + widget.plan.author.authorProfileIcon), fit: BoxFit.cover), + image: DecorationImage( + image: NetworkImage( + joinBaseUrl( + sl().baseUrl, + widget.plan.author.authorProfileIcon, + ), + ), + fit: BoxFit.cover, + ), ), ), subtitle: Row( @@ -155,22 +203,37 @@ class _PlanWidgetState extends State { 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, + ), + ), ), ), ), @@ -181,12 +244,21 @@ class _PlanWidgetState extends State { 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().likePlan(widget.plan.slug, widget.index); + await context.read().likePlan( + widget.plan.slug, + widget.index, + ); }, ), const SizedBox(width: 12), @@ -194,9 +266,14 @@ class _PlanWidgetState extends State { 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), @@ -206,8 +283,11 @@ class _PlanWidgetState extends State { final box = context.findRenderObject() as RenderBox?; final result = await SharePlus.instance.share( ShareParams( - uri: Uri.parse('${sl().baseUrl}/ru/diet/${widget.plan.id}'), - sharePositionOrigin: box!.localToGlobal(Offset.zero) & box.size, + uri: Uri.parse( + '${sl().baseUrl}/ru/diet/${widget.plan.id}', + ), + sharePositionOrigin: + box!.localToGlobal(Offset.zero) & box.size, ), ); switch (result.status) { diff --git a/lib/features/menu/presentation/bloc/notification_settings_cubit/notification_settings_cubit.dart b/lib/features/menu/presentation/bloc/notification_settings_cubit/notification_settings_cubit.dart index 60b0986d..2c8a5fc5 100644 --- a/lib/features/menu/presentation/bloc/notification_settings_cubit/notification_settings_cubit.dart +++ b/lib/features/menu/presentation/bloc/notification_settings_cubit/notification_settings_cubit.dart @@ -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 { final NotificationSettingsRepository _notificationSettingsRepository; + int _alertSeq = 0; NotificationSettingsCubit({required NotificationSettingsRepository notificationSettingsRepository}) : _notificationSettingsRepository = notificationSettingsRepository, @@ -30,6 +32,17 @@ class NotificationSettingsCubit extends Cubit { } 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())); } } diff --git a/lib/features/menu/presentation/bloc/notification_settings_cubit/notification_settings_state.dart b/lib/features/menu/presentation/bloc/notification_settings_cubit/notification_settings_state.dart index ef461f7c..5e5470a3 100644 --- a/lib/features/menu/presentation/bloc/notification_settings_cubit/notification_settings_state.dart +++ b/lib/features/menu/presentation/bloc/notification_settings_cubit/notification_settings_state.dart @@ -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 get props => [notificationSettings]; + List get props => [notificationSettings, alertMessage, alertId]; } class NotificationSettingsStateError extends NotificationSettingsState { diff --git a/lib/features/menu/presentation/notification_settings_user_message.dart b/lib/features/menu/presentation/notification_settings_user_message.dart new file mode 100644 index 00000000..dd076677 --- /dev/null +++ b/lib/features/menu/presentation/notification_settings_user_message.dart @@ -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; +} diff --git a/lib/features/menu/presentation/pages/notifications_settings_screen.dart b/lib/features/menu/presentation/pages/notifications_settings_screen.dart index e2afa516..2bce5518 100644 --- a/lib/features/menu/presentation/pages/notifications_settings_screen.dart +++ b/lib/features/menu/presentation/pages/notifications_settings_screen.dart @@ -27,7 +27,37 @@ class NotificationsSettingsContent extends StatelessWidget { return Scaffold( appBar: AppBar(title: Text(s.notifications)), - body: BlocBuilder( + body: BlocConsumer( + 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( + 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()); diff --git a/lib/features/menu/presentation/widgets/blocked_user_widget.dart b/lib/features/menu/presentation/widgets/blocked_user_widget.dart index c2b5f2c8..c2971def 100644 --- a/lib/features/menu/presentation/widgets/blocked_user_widget.dart +++ b/lib/features/menu/presentation/widgets/blocked_user_widget.dart @@ -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 { @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().baseUrl + widget.profile.profilePhotoUrl), + foregroundImage: NetworkImage( + joinBaseUrl( + sl().baseUrl, + widget.profile.profilePhotoUrl, + ), + ), backgroundImage: const AssetImage('assets/images/no_avatar.png'), radius: 28, ), @@ -50,11 +55,16 @@ class _BlockedUserWidgetState extends State { const Spacer(), InkWell( onTap: () { - context.read().unblockUser(widget.profile.slug); + context.read().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), diff --git a/lib/features/personal_diet_suggestion/presentation/consultation_widget.dart b/lib/features/personal_diet_suggestion/presentation/consultation_widget.dart index fa64362c..a9fcef6e 100644 --- a/lib/features/personal_diet_suggestion/presentation/consultation_widget.dart +++ b/lib/features/personal_diet_suggestion/presentation/consultation_widget.dart @@ -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().baseUrl + consultationModel.userProfilePhotoUrl), + backgroundImage: NetworkImage( + joinBaseUrl( + sl().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().baseUrl + consultationModel.userProfilePhotoUrl), + backgroundImage: NetworkImage( + joinBaseUrl( + sl().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().attachDietToConsultation(consultationId: consultationModel.id); + final result = await context + .read() + .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().attachDietToConsultation(consultationId: consultationModel.id); + final result = await context + .read() + .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 { 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 createState() => _SuggestionSuccessBottomSheet(); + State createState() => + _SuggestionSuccessBottomSheet(); } -class _SuggestionSuccessBottomSheet extends State { +class _SuggestionSuccessBottomSheet + extends State { @override Widget build(BuildContext context) { final s = S.of(context); @@ -261,37 +302,35 @@ class _SuggestionSuccessBottomSheet extends State 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 createState() => _SuggestionFailureBottomSheet(); + State createState() => + _SuggestionFailureBottomSheet(); } -class _SuggestionFailureBottomSheet extends State { +class _SuggestionFailureBottomSheet + extends State { @override Widget build(BuildContext context) { final s = S.of(context); @@ -320,37 +361,35 @@ class _SuggestionFailureBottomSheet extends State 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), ], ), ); diff --git a/lib/features/profile/presentation/pages/profile_screen.dart b/lib/features/profile/presentation/pages/profile_screen.dart index 5875bd17..b35733f0 100644 --- a/lib/features/profile/presentation/pages/profile_screen.dart +++ b/lib/features/profile/presentation/pages/profile_screen.dart @@ -9,6 +9,7 @@ import 'package:eatvio_app/core/permission_handler/permission_handler.dart'; import 'package:eatvio_app/core/routing/route_constants.dart'; import 'package:eatvio_app/core/theme/custom_text_style.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/bottom_sheet_with_blur.dart'; import 'package:eatvio_app/core/widgets/data_error_widget.dart'; import 'package:eatvio_app/core/widgets/loader.dart'; @@ -60,7 +61,13 @@ class _ProfileScreenState extends State { late final ProfileCubit profileCubit = sl(); final PlansCubit plansCubit = sl(); final List chipsLabelList = ['Вес', 'Замеры', 'ИМТ']; - final List chipsIntervalLabelList = ['1М', '3М', '6М', '1Г', 'ВСЁ ВРЕМЯ']; + final List chipsIntervalLabelList = [ + '1М', + '3М', + '6М', + '1Г', + 'ВСЁ ВРЕМЯ', + ]; final List chipsIntervalList = ['1', '3', '6', '12', 'all']; final TextEditingController controller = TextEditingController(); @@ -75,7 +82,9 @@ class _ProfileScreenState extends State { @override void initState() { super.initState(); - widget.slug == null ? profileCubit.getMyProfile() : profileCubit.getProfileBySlug(widget.slug!); + widget.slug == null + ? profileCubit.getMyProfile() + : profileCubit.getProfileBySlug(widget.slug!); } @override @@ -114,7 +123,11 @@ class _ProfileScreenState extends State { Text( 'Пользователь ограничил доступ к своей странице', textAlign: TextAlign.center, - style: TextStyle(fontSize: 18, fontWeight: FontWeight.w500, color: Colors.grey[600]), + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w500, + color: Colors.grey[600], + ), ), ], ), @@ -122,7 +135,9 @@ class _ProfileScreenState extends State { ), ); } - return state.isMyProfile ? _myProfileScreen(state) : _profileScreen(state); + return state.isMyProfile + ? _myProfileScreen(state) + : _profileScreen(state); } else if (state is ProfileStateError) { return Scaffold( appBar: AppBar( @@ -138,7 +153,9 @@ class _ProfileScreenState extends State { ), body: DataErrorWidget( onReload: () async { - widget.slug == null ? await profileCubit.getMyProfile() : await profileCubit.getProfileBySlug(widget.slug!); + widget.slug == null + ? await profileCubit.getMyProfile() + : await profileCubit.getProfileBySlug(widget.slug!); }, ), ); @@ -184,13 +201,16 @@ class _ProfileScreenState extends State { Container( decoration: const BoxDecoration( color: Colors.white, - borderRadius: BorderRadius.vertical(bottom: Radius.circular(16)), + borderRadius: BorderRadius.vertical( + bottom: Radius.circular(16), + ), ), child: Column( crossAxisAlignment: CrossAxisAlignment.center, children: [ _header(state), - if (state.profile.roles.contains('consultant') && state.consultantInfo != null) + if (state.profile.roles.contains('consultant') && + state.consultantInfo != null) Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -199,33 +219,56 @@ class _ProfileScreenState extends State { const SizedBox(width: 16), const Text( 'Консультант', - style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: Color(0xFF323F4B)), + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: Color(0xFF323F4B), + ), ), const SizedBox(width: 12), Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - decoration: BoxDecoration(color: const Color(0xFFF6FAFF), borderRadius: BorderRadius.circular(12)), + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 8, + ), + decoration: BoxDecoration( + color: const Color(0xFFF6FAFF), + borderRadius: BorderRadius.circular(12), + ), child: Text( '${state.consultantInfo!.ratingPlace}-й в рейтинге', - style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: appTheme.blueA400), + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: appTheme.blueA400, + ), ), ), ], ), - if (state.consultantInfo!.educationModel.hasMedicalEducation == 1) + if (state + .consultantInfo! + .educationModel + .hasMedicalEducation == + 1) const Row( children: [ SizedBox(width: 16), Icon(Icons.done_rounded, color: Colors.green), Text( 'Медицинское образование', - style: TextStyle(color: Color(0xFF323F4B), fontSize: 14, fontWeight: FontWeight.w600), + style: TextStyle( + color: Color(0xFF323F4B), + fontSize: 14, + fontWeight: FontWeight.w600, + ), ), ], ), ], ), - if (state.profileDetails.additionalInfo.isNotEmpty) _additionalInfo(state), + if (state.profileDetails.additionalInfo.isNotEmpty) + _additionalInfo(state), if (!state.profile.isBlockedByMe) const SizedBox(height: 8), if (!state.profile.isBlockedByMe) _messageAndSubscribe(state), if (!state.profile.isBlockedByMe) const SizedBox(height: 16), @@ -233,7 +276,11 @@ class _ProfileScreenState extends State { ), ), _subscriptionsAndSubscribers(state), - if (state.profile.badges.isNotEmpty) Padding(padding: const EdgeInsets.only(bottom: 16), child: _badges(state)), + if (state.profile.badges.isNotEmpty) + Padding( + padding: const EdgeInsets.only(bottom: 16), + child: _badges(state), + ), if (state.profile.isBlockedByAuthor) Column( mainAxisAlignment: MainAxisAlignment.center, @@ -244,16 +291,27 @@ class _ProfileScreenState extends State { child: Text( 'Пользователь ограничил доступ\nк своей странице', textAlign: TextAlign.center, - style: TextStyles.textMSemibold.copyWith(color: appTheme.grey7B), + style: TextStyles.textMSemibold.copyWith( + color: appTheme.grey7B, + ), ), ), ], ), - if (state.profile.roles.contains('consultant') && state.consultantInfo != null && !state.profile.isBlockedByMe && !state.profile.isBlockedByAuthor) + if (state.profile.roles.contains('consultant') && + state.consultantInfo != null && + !state.profile.isBlockedByMe && + !state.profile.isBlockedByAuthor) _socialLinks(state), - if (state.profile.roles.contains('consultant') && state.consultantInfo != null && !state.profile.isBlockedByMe && !state.profile.isBlockedByAuthor) + if (state.profile.roles.contains('consultant') && + state.consultantInfo != null && + !state.profile.isBlockedByMe && + !state.profile.isBlockedByAuthor) _education(state), - if (state.profile.roles.contains('consultant') && state.consultantInfo != null && !state.profile.isBlockedByMe && !state.profile.isBlockedByAuthor) + if (state.profile.roles.contains('consultant') && + state.consultantInfo != null && + !state.profile.isBlockedByMe && + !state.profile.isBlockedByAuthor) _diplomasAndCertificates(state), if (state.profile.roles.contains('consultant') && state.consultantInfo != null && @@ -261,15 +319,24 @@ class _ProfileScreenState extends State { !state.profile.isBlockedByMe && !state.profile.isBlockedByAuthor) _activePlans(state), - if (state.blogPosts.isNotEmpty && !state.profile.isBlockedByAuthor && !state.profile.isBlockedByMe) + if (state.blogPosts.isNotEmpty && + !state.profile.isBlockedByAuthor && + !state.profile.isBlockedByMe) const Padding( padding: EdgeInsets.only(left: 8, top: 16, bottom: 8), child: Text( 'Блог', - style: TextStyle(color: Color(0xFF323F4B), fontSize: 24, fontWeight: FontWeight.w600), + style: TextStyle( + color: Color(0xFF323F4B), + fontSize: 24, + fontWeight: FontWeight.w600, + ), ), ), - if (state.blogPosts.isNotEmpty && !state.profile.isBlockedByAuthor && !state.profile.isBlockedByMe) _blogPosts(state), + if (state.blogPosts.isNotEmpty && + !state.profile.isBlockedByAuthor && + !state.profile.isBlockedByMe) + _blogPosts(state), ], ), ), @@ -298,7 +365,9 @@ class _ProfileScreenState extends State { Container( decoration: const BoxDecoration( color: Colors.white, - borderRadius: BorderRadius.vertical(bottom: Radius.circular(16)), + borderRadius: BorderRadius.vertical( + bottom: Radius.circular(16), + ), ), child: Column( crossAxisAlignment: CrossAxisAlignment.center, @@ -313,43 +382,75 @@ class _ProfileScreenState extends State { const SizedBox(width: 16), const Text( 'Консультант', - style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: Color(0xFF323F4B)), + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: Color(0xFF323F4B), + ), ), const SizedBox(width: 12), Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - decoration: BoxDecoration(color: const Color(0xFFF6FAFF), borderRadius: BorderRadius.circular(12)), + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 8, + ), + decoration: BoxDecoration( + color: const Color(0xFFF6FAFF), + borderRadius: BorderRadius.circular(12), + ), child: Text( '${state.consultantInfo!.ratingPlace}-й в рейтинге', - style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: appTheme.blueA400), + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: appTheme.blueA400, + ), ), ), ], ), - if (state.consultantInfo!.educationModel.hasMedicalEducation == 1) + if (state + .consultantInfo! + .educationModel + .hasMedicalEducation == + 1) const Row( children: [ SizedBox(width: 16), Icon(Icons.done_rounded, color: Colors.green), Text( 'Медицинское образование', - style: TextStyle(color: Color(0xFF323F4B), fontSize: 14, fontWeight: FontWeight.w600), + style: TextStyle( + color: Color(0xFF323F4B), + fontSize: 14, + fontWeight: FontWeight.w600, + ), ), ], ), ], ), - if (state.profileDetails.additionalInfo.isNotEmpty) _additionalInfo(state), + if (state.profileDetails.additionalInfo.isNotEmpty) + _additionalInfo(state), ], ), ), _subscriptionsAndSubscribers(state), - if (state.profile.badges.isNotEmpty) Padding(padding: const EdgeInsets.only(bottom: 16), child: _badges(state)), + if (state.profile.badges.isNotEmpty) + Padding( + padding: const EdgeInsets.only(bottom: 16), + child: _badges(state), + ), if (state.profile.roles.contains('consultant')) _socialLinks(state), - Padding(padding: const EdgeInsets.only(bottom: 16), child: _charts()), + Padding( + padding: const EdgeInsets.only(bottom: 16), + child: _charts(), + ), if (state.profile.roles.contains('consultant')) _education(state), - if (state.profile.roles.contains('consultant')) _diplomasAndCertificates(state), - if (state.profile.roles.contains('consultant') && !hidePlans) _activePlans(state), + if (state.profile.roles.contains('consultant')) + _diplomasAndCertificates(state), + if (state.profile.roles.contains('consultant') && !hidePlans) + _activePlans(state), _newBlogPostButton(), _blogPosts(state), ], @@ -378,7 +479,12 @@ class _ProfileScreenState extends State { children: [ Text( 'Активные планы', - style: TextStyle(color: Color(0xFF323F4B), fontSize: 24, height: 1, fontWeight: FontWeight.w600), + style: TextStyle( + color: Color(0xFF323F4B), + fontSize: 24, + height: 1, + fontWeight: FontWeight.w600, + ), ), ], ), @@ -409,11 +515,19 @@ class _ProfileScreenState extends State { Container _bullets(int currentBullet, int bulletCount) { return Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(20)), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(20), + ), child: Row( mainAxisSize: MainAxisSize.min, children: [ - for (int i = 0; i < bulletCount; i++) ...[_bullet(i == currentBullet), i < bulletCount - 1 ? const SizedBox(width: 4) : const SizedBox.shrink()], + for (int i = 0; i < bulletCount; i++) ...[ + _bullet(i == currentBullet), + i < bulletCount - 1 + ? const SizedBox(width: 4) + : const SizedBox.shrink(), + ], ], ), ); @@ -424,7 +538,10 @@ class _ProfileScreenState extends State { width: isActive ? 10 : 8, height: isActive ? 10 : 8, duration: const Duration(milliseconds: 150), - decoration: BoxDecoration(color: isActive ? appTheme.blueA400 : const Color(0xFFD7D9DC), shape: BoxShape.circle), + decoration: BoxDecoration( + color: isActive ? appTheme.blueA400 : const Color(0xFFD7D9DC), + shape: BoxShape.circle, + ), ); } @@ -444,15 +561,25 @@ class _ProfileScreenState extends State { borderRadius: BorderRadius.circular(8), child: Ink( padding: const EdgeInsets.all(12), - decoration: BoxDecoration(color: const Color(0xFFF6FAFF), borderRadius: BorderRadius.circular(8)), + decoration: BoxDecoration( + color: const Color(0xFFF6FAFF), + borderRadius: BorderRadius.circular(8), + ), child: const Row( mainAxisAlignment: MainAxisAlignment.center, children: [ - Icon(Icons.add_circle_outline_rounded, color: Color(0xFF2F80ED)), + Icon( + Icons.add_circle_outline_rounded, + color: Color(0xFF2F80ED), + ), SizedBox(width: 12), Text( 'Новая запись', - style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: Color(0xFF2F80ED)), + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + color: Color(0xFF2F80ED), + ), ), ], ), @@ -462,7 +589,9 @@ class _ProfileScreenState extends State { ); } - SingleChildRenderObjectWidget _diplomasAndCertificates(ProfileStateLoaded state) { + SingleChildRenderObjectWidget _diplomasAndCertificates( + ProfileStateLoaded state, + ) { if (!state.profile.roles.contains('consultant')) { return const SizedBox.shrink(); } @@ -484,15 +613,27 @@ class _ProfileScreenState extends State { children: [ const Text( 'Дипломы\nи сертификаты', - style: TextStyle(color: Color(0xFF323F4B), fontSize: 24, height: 1, fontWeight: FontWeight.w600), + style: TextStyle( + color: Color(0xFF323F4B), + fontSize: 24, + height: 1, + fontWeight: FontWeight.w600, + ), ), GestureDetector( onTap: () { - GoRouter.of(context).pushNamed('stepperBecomeConsultantScreen', extra: {'slug': state.profile.slug}); + GoRouter.of(context).pushNamed( + 'stepperBecomeConsultantScreen', + extra: {'slug': state.profile.slug}, + ); }, child: Text( state.isMyProfile ? 'Редактировать' : '', - style: const TextStyle(color: Color(0xFF2F80ED), fontSize: 16, fontWeight: FontWeight.w500), + style: const TextStyle( + color: Color(0xFF2F80ED), + fontSize: 16, + fontWeight: FontWeight.w500, + ), ), ), ], @@ -508,18 +649,43 @@ class _ProfileScreenState extends State { child: SingleChildScrollView( physics: const BouncingScrollPhysics(), scrollDirection: Axis.horizontal, - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), child: Row( children: [ if (state.isMyProfile && state.certPhotos.isNotEmpty) ...List.generate(state.certPhotos.length, (index) { final photo = state.certPhotos[index]; - return Row(children: [_diplomaImage(photo.url, state.isMyProfile, photo.isHidden, photo.id), const SizedBox(width: 8)]); + return Row( + children: [ + _diplomaImage( + photo.url, + state.isMyProfile, + photo.isHidden, + photo.id, + ), + const SizedBox(width: 8), + ], + ); }) else if (state.consultantInfo!.photos.isNotEmpty) - ...List.generate(state.consultantInfo!.photos.length, (index) { + ...List.generate(state.consultantInfo!.photos.length, ( + index, + ) { final photo = state.consultantInfo!.photos[index]; - return Row(children: [_diplomaImage(photo.url, state.isMyProfile, false, null), const SizedBox(width: 8)]); + return Row( + children: [ + _diplomaImage( + photo.url, + state.isMyProfile, + false, + null, + ), + const SizedBox(width: 8), + ], + ); }), ], ), @@ -531,10 +697,18 @@ class _ProfileScreenState extends State { ); } - GestureDetector _diplomaImage(String url, bool isMyProfile, bool isHidden, int? certPhotoId) { + GestureDetector _diplomaImage( + String url, + bool isMyProfile, + bool isHidden, + int? certPhotoId, + ) { return GestureDetector( onTap: () { - context.pushTransparentRoute(rootNavigator: true, PhotoViewScreen(imageUrls: [url], index: 0)); + context.pushTransparentRoute( + rootNavigator: true, + PhotoViewScreen(imageUrls: [url], index: 0), + ); }, child: Hero( tag: url, @@ -542,7 +716,12 @@ class _ProfileScreenState extends State { children: [ ClipRRect( borderRadius: BorderRadius.circular(8), - child: CachedNetworkImage(imageUrl: url, fit: BoxFit.fill, width: MediaQuery.of(context).size.width / 3 - (60 / 3), height: 160), + child: CachedNetworkImage( + imageUrl: url, + fit: BoxFit.fill, + width: MediaQuery.of(context).size.width / 3 - (60 / 3), + height: 160, + ), ), if (isMyProfile) Positioned( @@ -555,19 +734,34 @@ class _ProfileScreenState extends State { userRootNavigator: true, context: context, height: 250, - child: ToggleCertPhotoBottomSheet(profileCubit: profileCubit, isPhotoHidden: isHidden, id: certPhotoId), + child: ToggleCertPhotoBottomSheet( + profileCubit: profileCubit, + isPhotoHidden: isHidden, + id: certPhotoId, + ), ); } }, child: Container( - decoration: BoxDecoration(color: appTheme.whiteA700, borderRadius: BorderRadius.circular(4)), + decoration: BoxDecoration( + color: appTheme.whiteA700, + borderRadius: BorderRadius.circular(4), + ), child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 5), + padding: const EdgeInsets.symmetric( + horizontal: 4, + vertical: 5, + ), child: SvgPicture.asset( - isHidden ? 'assets/icons/crossed_eye.svg' : 'assets/icons/eye2.svg', + isHidden + ? 'assets/icons/crossed_eye.svg' + : 'assets/icons/eye2.svg', width: 10, height: 8, - colorFilter: ColorFilter.mode(appTheme.blueA400, BlendMode.srcIn), + colorFilter: ColorFilter.mode( + appTheme.blueA400, + BlendMode.srcIn, + ), ), ), ), @@ -582,7 +776,8 @@ class _ProfileScreenState extends State { SingleChildRenderObjectWidget _education(ProfileStateLoaded state) { if (!state.profile.roles.contains('consultant') || state.consultantInfo!.educationModel.completedCourses == null || - (state.consultantInfo!.educationModel.completedCourses?.isEmpty ?? false)) { + (state.consultantInfo!.educationModel.completedCourses?.isEmpty ?? + false)) { return const SizedBox.shrink(); } return Padding( @@ -596,15 +791,26 @@ class _ProfileScreenState extends State { children: [ const Text( 'Образование', - style: TextStyle(color: Color(0xFF323F4B), fontSize: 24, fontWeight: FontWeight.w600), + style: TextStyle( + color: Color(0xFF323F4B), + fontSize: 24, + fontWeight: FontWeight.w600, + ), ), GestureDetector( onTap: () { - GoRouter.of(context).pushNamed('stepperBecomeConsultantScreen', extra: {'slug': state.profile.slug}); + GoRouter.of(context).pushNamed( + 'stepperBecomeConsultantScreen', + extra: {'slug': state.profile.slug}, + ); }, child: Text( state.isMyProfile ? 'Редактировать' : '', - style: const TextStyle(color: Color(0xFF2F80ED), fontSize: 16, fontWeight: FontWeight.w500), + style: const TextStyle( + color: Color(0xFF2F80ED), + fontSize: 16, + fontWeight: FontWeight.w500, + ), ), ), ], @@ -625,11 +831,28 @@ class _ProfileScreenState extends State { padding: const EdgeInsets.all(0), physics: const NeverScrollableScrollPhysics(), itemBuilder: (context, index) => _educationBlock( - state.consultantInfo!.educationModel.completedCourses![index].values.first.title, - state.consultantInfo!.educationModel.completedCourses![index].values.first.date, + state + .consultantInfo! + .educationModel + .completedCourses![index] + .values + .first + .title, + state + .consultantInfo! + .educationModel + .completedCourses![index] + .values + .first + .date, ), - separatorBuilder: (context, index) => const SizedBox(height: 8), - itemCount: state.consultantInfo!.educationModel.completedCourses!.length, + separatorBuilder: (context, index) => + const SizedBox(height: 8), + itemCount: state + .consultantInfo! + .educationModel + .completedCourses! + .length, ), ], ), @@ -643,7 +866,9 @@ class _ProfileScreenState extends State { SizedBox _educationBlock(String title, String date) { final parsedDate = DateTime.tryParse(date); - final formattedDate = parsedDate != null ? DateFormat('d MMMM y', 'ru_RU').format(parsedDate) : date; + final formattedDate = parsedDate != null + ? DateFormat('d MMMM y', 'ru_RU').format(parsedDate) + : date; return SizedBox( width: double.infinity, @@ -657,12 +882,22 @@ class _ProfileScreenState extends State { children: [ Text( title, - style: const TextStyle(height: 1, color: Color(0xFF323F4B), fontWeight: FontWeight.w500, fontSize: 16), + style: const TextStyle( + height: 1, + color: Color(0xFF323F4B), + fontWeight: FontWeight.w500, + fontSize: 16, + ), ), const SizedBox(height: 4), Text( formattedDate, - style: const TextStyle(height: 1, color: Color(0xFF6D6F71), fontWeight: FontWeight.w500, fontSize: 14), + style: const TextStyle( + height: 1, + color: Color(0xFF6D6F71), + fontWeight: FontWeight.w500, + fontSize: 14, + ), ), ], ), @@ -672,11 +907,14 @@ class _ProfileScreenState extends State { } SingleChildRenderObjectWidget _socialLinks(ProfileStateLoaded state) { - if (!state.profile.roles.contains('consultant') && state.consultantInfo != null) { + if (!state.profile.roles.contains('consultant') && + state.consultantInfo != null) { return const SizedBox.shrink(); } return Padding( - padding: const EdgeInsets.symmetric(horizontal: 4) + const EdgeInsets.only(bottom: 16), + padding: + const EdgeInsets.symmetric(horizontal: 4) + + const EdgeInsets.only(bottom: 16), child: Material( color: Colors.white, borderRadius: BorderRadius.circular(12), @@ -686,26 +924,48 @@ class _ProfileScreenState extends State { children: [ _socialLinkButton( () { - launchUrl(Uri.parse(state.consultantInfo!.educationModel.vkLink ?? ''), mode: LaunchMode.externalApplication); + launchUrl( + Uri.parse( + state.consultantInfo!.educationModel.vkLink ?? '', + ), + mode: LaunchMode.externalApplication, + ); }, 'assets/icons/vk.svg', - state.consultantInfo!.educationModel.vkLink!.isEmpty ? true : false, + state.consultantInfo!.educationModel.vkLink!.isEmpty + ? true + : false, ), const SizedBox(width: 12), _socialLinkButton( () { - launchUrl(Uri.parse(state.consultantInfo!.educationModel.tgLink ?? ''), mode: LaunchMode.externalApplication); + launchUrl( + Uri.parse( + state.consultantInfo!.educationModel.tgLink ?? '', + ), + mode: LaunchMode.externalApplication, + ); }, 'assets/icons/telegram.svg', - state.consultantInfo!.educationModel.tgLink!.isEmpty ? true : false, + state.consultantInfo!.educationModel.tgLink!.isEmpty + ? true + : false, ), const SizedBox(width: 12), _socialLinkButton( () { - launchUrl(Uri.parse(state.consultantInfo!.educationModel.personalSiteLink ?? ''), mode: LaunchMode.externalApplication); + launchUrl( + Uri.parse( + state.consultantInfo!.educationModel.personalSiteLink ?? + '', + ), + mode: LaunchMode.externalApplication, + ); }, 'assets/icons/web.svg', - state.consultantInfo!.educationModel.personalSiteLink!.isEmpty ? true : false, + state.consultantInfo!.educationModel.personalSiteLink!.isEmpty + ? true + : false, ), ], ), @@ -720,13 +980,18 @@ class _ProfileScreenState extends State { borderRadius: BorderRadius.circular(20), child: Ink( width: MediaQuery.of(context).size.width / 3 - (56 / 3), - decoration: BoxDecoration(borderRadius: BorderRadius.circular(20), color: const Color(0xFFF6F6F6)), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(20), + color: const Color(0xFFF6F6F6), + ), child: Padding( padding: const EdgeInsets.symmetric(horizontal: 40, vertical: 16), child: SvgPicture.asset( icon, width: 24, - colorFilter: disabled ? const ColorFilter.mode(Colors.black12, BlendMode.srcIn) : const ColorFilter.mode(Colors.black, BlendMode.srcIn), + colorFilter: disabled + ? const ColorFilter.mode(Colors.black12, BlendMode.srcIn) + : const ColorFilter.mode(Colors.black, BlendMode.srcIn), ), ), ), @@ -742,13 +1007,20 @@ class _ProfileScreenState extends State { child: Ink( padding: const EdgeInsets.all(8), width: MediaQuery.of(context).size.width, - decoration: BoxDecoration(borderRadius: BorderRadius.circular(12), color: Colors.white), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + color: Colors.white, + ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ const Text( 'Достижения', - style: TextStyle(color: Color(0xFF6D6F71), fontSize: 14, fontWeight: FontWeight.w600), + style: TextStyle( + color: Color(0xFF6D6F71), + fontSize: 14, + fontWeight: FontWeight.w600, + ), ), const SizedBox(height: 8), SingleChildScrollView( @@ -764,22 +1036,47 @@ class _ProfileScreenState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(state.profile.badges[i].title, style: TextStyles.textMSemibold.copyWith(color: appTheme.black)), + Text( + state.profile.badges[i].title, + style: TextStyles.textMSemibold.copyWith( + color: appTheme.black, + ), + ), const SizedBox(height: 4), - Text(state.profile.badges[i].description, style: TextStyles.textSMedium.copyWith(color: appTheme.gray600)), + Text( + state.profile.badges[i].description, + style: TextStyles.textSMedium.copyWith( + color: appTheme.gray600, + ), + ), ], ), ), triggerMode: TooltipTriggerMode.tap, - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), - textStyle: const TextStyle(color: Colors.black, fontSize: 16, fontWeight: FontWeight.w500), + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), + textStyle: const TextStyle( + color: Colors.black, + fontSize: 16, + fontWeight: FontWeight.w500, + ), decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(12), - boxShadow: [BoxShadow(color: Colors.black.withAlpha((255 * 0.2).toInt()), blurRadius: 32)], + boxShadow: [ + BoxShadow( + color: Colors.black.withAlpha( + (255 * 0.2).toInt(), + ), + blurRadius: 32, + ), + ], ), child: NetworkSvgPicture( - url: '${sl().baseUrl}/storage/${state.profile.badges[i].image}', + url: + '${sl().baseUrl}/storage/${state.profile.badges[i].image}', width: 40, height: 40, ), @@ -791,23 +1088,54 @@ class _ProfileScreenState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(state.profile.badges[i].title, style: TextStyles.textMSemibold.copyWith(color: appTheme.black)), + Text( + state.profile.badges[i].title, + style: TextStyles.textMSemibold.copyWith( + color: appTheme.black, + ), + ), const SizedBox(height: 4), - Text(state.profile.badges[i].description, style: TextStyles.textSMedium.copyWith(color: appTheme.gray600)), + Text( + state.profile.badges[i].description, + style: TextStyles.textSMedium.copyWith( + color: appTheme.gray600, + ), + ), ], ), ), triggerMode: TooltipTriggerMode.tap, - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), - textStyle: const TextStyle(color: Colors.black, fontSize: 16, fontWeight: FontWeight.w500), + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), + textStyle: const TextStyle( + color: Colors.black, + fontSize: 16, + fontWeight: FontWeight.w500, + ), decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(12), - boxShadow: [BoxShadow(color: Colors.black.withAlpha((255 * 0.2).toInt()), blurRadius: 32)], + boxShadow: [ + BoxShadow( + color: Colors.black.withAlpha( + (255 * 0.2).toInt(), + ), + blurRadius: 32, + ), + ], + ), + child: CachedNetworkImage( + imageUrl: + '${sl().baseUrl}/storage/${state.profile.badges[i].image}', + width: 40, + height: 40, ), - child: CachedNetworkImage(imageUrl: '${sl().baseUrl}/storage/${state.profile.badges[i].image}', width: 40, height: 40), ), - i < state.profile.badges.length - 1 ? const SizedBox(width: 8) : const SizedBox.shrink(), + i < state.profile.badges.length - 1 + ? const SizedBox(width: 8) + : const SizedBox.shrink(), ], ], ), @@ -825,12 +1153,19 @@ class _ProfileScreenState extends State { child: Row( children: [ InkWell( - onTap: () => GoRouter.of(context).pushNamed('subsScreen', extra: SubType.subscriptions, pathParameters: {'slug': state.profile.slug}), + onTap: () => GoRouter.of(context).pushNamed( + 'subsScreen', + extra: SubType.subscriptions, + pathParameters: {'slug': state.profile.slug}, + ), borderRadius: BorderRadius.circular(12), child: Ink( padding: const EdgeInsets.all(8), width: MediaQuery.of(context).size.width / 2 - 6, - decoration: BoxDecoration(borderRadius: BorderRadius.circular(12), color: Colors.white), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + color: Colors.white, + ), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, crossAxisAlignment: CrossAxisAlignment.end, @@ -840,27 +1175,45 @@ class _ProfileScreenState extends State { children: [ const Text( 'Подписки', - style: TextStyle(color: Color(0xFF6D6F71), fontSize: 14, fontWeight: FontWeight.w600), + style: TextStyle( + color: Color(0xFF6D6F71), + fontSize: 14, + fontWeight: FontWeight.w600, + ), ), Text( '${state.profile.subscriptions.length}', - style: const TextStyle(color: Color(0xFF323F4B), fontSize: 22, fontWeight: FontWeight.w700), + style: const TextStyle( + color: Color(0xFF323F4B), + fontSize: 22, + fontWeight: FontWeight.w700, + ), ), ], ), - Padding(padding: const EdgeInsets.only(right: 8), child: _miniAvatars(state.profile.subscriptions)), + Padding( + padding: const EdgeInsets.only(right: 8), + child: _miniAvatars(state.profile.subscriptions), + ), ], ), ), ), const SizedBox(width: 4), InkWell( - onTap: () => GoRouter.of(context).pushNamed('subsScreen', extra: SubType.subscribers, pathParameters: {'slug': state.profile.slug}), + onTap: () => GoRouter.of(context).pushNamed( + 'subsScreen', + extra: SubType.subscribers, + pathParameters: {'slug': state.profile.slug}, + ), borderRadius: BorderRadius.circular(12), child: Ink( padding: const EdgeInsets.all(8), width: MediaQuery.of(context).size.width / 2 - 6, - decoration: BoxDecoration(borderRadius: BorderRadius.circular(12), color: Colors.white), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + color: Colors.white, + ), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, crossAxisAlignment: CrossAxisAlignment.end, @@ -870,15 +1223,26 @@ class _ProfileScreenState extends State { children: [ const Text( 'Подписчики', - style: TextStyle(color: Color(0xFF6D6F71), fontSize: 14, fontWeight: FontWeight.w600), + style: TextStyle( + color: Color(0xFF6D6F71), + fontSize: 14, + fontWeight: FontWeight.w600, + ), ), Text( '${state.profile.subscribers.length}', - style: const TextStyle(color: Color(0xFF323F4B), fontSize: 22, fontWeight: FontWeight.w700), + style: const TextStyle( + color: Color(0xFF323F4B), + fontSize: 22, + fontWeight: FontWeight.w700, + ), ), ], ), - Padding(padding: const EdgeInsets.only(right: 8), child: _miniAvatars(state.profile.subscribers)), + Padding( + padding: const EdgeInsets.only(right: 8), + child: _miniAvatars(state.profile.subscribers), + ), ], ), ), @@ -896,7 +1260,11 @@ class _ProfileScreenState extends State { for (int i = 0; i < maxLength; i++) Align( widthFactor: 0.8, - child: CircleAvatar(radius: 14, foregroundImage: NetworkImage(urls[i]), backgroundImage: const AssetImage('assets/images/no_avatar.png')), + child: CircleAvatar( + radius: 14, + foregroundImage: NetworkImage(urls[i]), + backgroundImage: const AssetImage('assets/images/no_avatar.png'), + ), ), ], ); @@ -907,7 +1275,8 @@ class _ProfileScreenState extends State { padding: const EdgeInsets.only(bottom: 16), shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), - itemBuilder: (context, index) => ProfileBlogPostWidget(index: index, profileCubit: profileCubit), + itemBuilder: (context, index) => + ProfileBlogPostWidget(index: index, profileCubit: profileCubit), separatorBuilder: (context, index) => const SizedBox(height: 8), itemCount: state.blogPosts.length, ); @@ -921,7 +1290,10 @@ class _ProfileScreenState extends State { return Container( alignment: Alignment.centerLeft, padding: const EdgeInsets.all(16), - child: Text(text, style: TextStyle(color: Colors.black.withValues(alpha: 0.4), height: 1)), + child: Text( + text, + style: TextStyle(color: Colors.black.withValues(alpha: 0.4), height: 1), + ), ); } @@ -931,7 +1303,9 @@ class _ProfileScreenState extends State { child: Row( children: [ PopupMenuButton( - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), color: Colors.white, constraints: const BoxConstraints(minWidth: 260), surfaceTintColor: Colors.transparent, @@ -943,7 +1317,15 @@ class _ProfileScreenState extends State { onTap: () async { context.pushTransparentRoute( rootNavigator: true, - PhotoViewScreen(imageUrls: [sl().baseUrl + state.profile.profilePhotoUrl], index: 0), + PhotoViewScreen( + imageUrls: [ + joinBaseUrl( + sl().baseUrl, + state.profile.profilePhotoUrl, + ), + ], + index: 0, + ), ); }, ), @@ -966,18 +1348,30 @@ class _ProfileScreenState extends State { title: s.allowAccessToCamera, description: s.allowAccessToCameraDescription, onGranted: () async { - AppRouter.navigatorKey.currentContext?.pop(); + 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) { - await profileCubit.uploadPhoto(File(image.path)); - await sl().getMyProfile(); + await profileCubit.uploadPhoto( + File(image.path), + ); + await sl() + .getMyProfile(); } }, context: context, ); }, - child: const Row(children: [Icon(Icons.photo_camera), SizedBox(width: 8), Text('Фото')]), + child: const Row( + children: [ + Icon(Icons.photo_camera), + SizedBox(width: 8), + Text('Фото'), + ], + ), ), CupertinoActionSheetAction( onPressed: () async { @@ -986,18 +1380,30 @@ class _ProfileScreenState extends State { title: s.allowAcessToGalery, description: s.allowAcessToGaleryDescription, onGranted: () async { - AppRouter.navigatorKey.currentContext?.pop(); + AppRouter.navigatorKey.currentContext + ?.pop(); final picker = ImagePicker(); - XFile? image = await picker.pickImage(source: ImageSource.gallery); + XFile? image = await picker.pickImage( + source: ImageSource.gallery, + ); if (image != null) { - await profileCubit.uploadPhoto(File(image.path)); - await sl().getMyProfile(); + await profileCubit.uploadPhoto( + File(image.path), + ); + await sl() + .getMyProfile(); } }, context: context, ); }, - child: const Row(children: [Icon(Icons.photo), SizedBox(width: 8), Text('Галерея')]), + child: const Row( + children: [ + Icon(Icons.photo), + SizedBox(width: 8), + Text('Галерея'), + ], + ), ), ], cancelButton: CupertinoActionSheetAction( @@ -1026,9 +1432,14 @@ class _ProfileScreenState extends State { child: CircleAvatar( radius: 32, foregroundImage: NetworkImage( - state.profile.profilePhotoUrl.contains('https') ? state.profile.profilePhotoUrl : sl().baseUrl + state.profile.profilePhotoUrl, + joinBaseUrl( + sl().baseUrl, + state.profile.profilePhotoUrl, + ), + ), + backgroundImage: const AssetImage( + 'assets/images/no_avatar.png', ), - backgroundImage: const AssetImage('assets/images/no_avatar.png'), ), ), ), @@ -1038,7 +1449,12 @@ class _ProfileScreenState extends State { children: [ Text( '${state.profileDetails.firstName} ${state.profileDetails.lastName}', - style: const TextStyle(color: Colors.black, fontWeight: FontWeight.w600, fontSize: 16, height: 1), + style: const TextStyle( + color: Colors.black, + fontWeight: FontWeight.w600, + fontSize: 16, + height: 1, + ), ), const SizedBox(height: 8), GestureDetector( @@ -1056,18 +1472,31 @@ class _ProfileScreenState extends State { children: [ const Text( 'Подробнее', - style: TextStyle(fontSize: 20, fontWeight: FontWeight.w700, color: Color(0xFF323F4B)), + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.w700, + color: Color(0xFF323F4B), + ), ), const SizedBox(height: 16), - _additional('Дата рожденя: ', state.profileDetails.birthdate), + _additional( + 'Дата рожденя: ', + state.profileDetails.birthdate, + ), const SizedBox(height: 12), _additional('Страна: ', state.profileDetails.country), const SizedBox(height: 12), _additional('Город: ', state.profileDetails.city), const SizedBox(height: 12), - _additional('Язык общения: ', state.profileDetails.lang), + _additional( + 'Язык общения: ', + state.profileDetails.lang, + ), const SizedBox(height: 12), - _additional('В Eatvio с: ', '${state.profile.createdAt?.format('d.MM.yyyy', 'ru')}'), + _additional( + 'В Eatvio с: ', + '${state.profile.createdAt?.format('d.MM.yyyy', 'ru')}', + ), const SizedBox(height: 12), ], ), @@ -1076,11 +1505,19 @@ class _ProfileScreenState extends State { }, child: const Row( children: [ - Icon(Icons.info_outline, size: 20, color: Color(0xFF6D6F71)), + Icon( + Icons.info_outline, + size: 20, + color: Color(0xFF6D6F71), + ), SizedBox(width: 4), Text( 'Дополнительно', - style: TextStyle(color: Color(0xFF6D6F71), fontSize: 14, fontWeight: FontWeight.w500), + style: TextStyle( + color: Color(0xFF6D6F71), + fontSize: 14, + fontWeight: FontWeight.w500, + ), ), ], ), @@ -1089,7 +1526,9 @@ class _ProfileScreenState extends State { ), const Spacer(), PopupMenuButton( - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), color: Colors.white, constraints: const BoxConstraints(minWidth: 260), surfaceTintColor: Colors.transparent, @@ -1102,13 +1541,17 @@ class _ProfileScreenState extends State { final box = context.findRenderObject() as RenderBox?; SharePlus.instance.share( ShareParams( - uri: Uri.parse('${sl().baseUrl}/ru/profile/${state.profile.slug}'), - sharePositionOrigin: box!.localToGlobal(Offset.zero) & box.size, + uri: Uri.parse( + '${sl().baseUrl}/ru/profile/${state.profile.slug}', + ), + sharePositionOrigin: + box!.localToGlobal(Offset.zero) & box.size, ), ); }, ), - if (!state.isMyProfile && !state.profile.roles.contains('admin')) ...[ + if (!state.isMyProfile && + !state.profile.roles.contains('admin')) ...[ _buildDivider(), _buildCompactItem( icon: 'assets/icons/complain.svg', @@ -1117,7 +1560,10 @@ class _ProfileScreenState extends State { showModalBottomSheet( context: context, isScrollControlled: true, - builder: (context) => ComplainingBottomSheet(complainedContentType: ComplainedContent.profile, slug: widget.slug), + builder: (context) => ComplainingBottomSheet( + complainedContentType: ComplainedContent.profile, + slug: widget.slug, + ), ); }, ), @@ -1130,7 +1576,9 @@ class _ProfileScreenState extends State { onTap: () { isFirstFetch = true; sl().blockUser(state.profile.slug); - state.isMyProfile ? profileCubit.getMyProfile() : profileCubit.getProfileBySlug(state.profile.slug); + state.isMyProfile + ? profileCubit.getMyProfile() + : profileCubit.getProfileBySlug(state.profile.slug); if (state.profile.roles.contains('consultant')) { plansCubit.getPlans(state.profile.slug); } @@ -1146,7 +1594,9 @@ class _ProfileScreenState extends State { onTap: () { isFirstFetch = true; sl().unblockUser(state.profile.slug); - state.isMyProfile ? profileCubit.getMyProfile() : profileCubit.getProfileBySlug(state.profile.slug); + state.isMyProfile + ? profileCubit.getMyProfile() + : profileCubit.getProfileBySlug(state.profile.slug); if (state.profile.roles.contains('consultant')) { plansCubit.getPlans(state.profile.slug); } @@ -1162,7 +1612,11 @@ class _ProfileScreenState extends State { ); } - PopupMenuItem _buildCompactItem({required String icon, required String text, required Function() onTap}) { + PopupMenuItem _buildCompactItem({ + required String icon, + required String text, + required Function() onTap, + }) { return PopupMenuItem( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), height: 36, @@ -1171,7 +1625,10 @@ class _ProfileScreenState extends State { width: double.infinity, child: Row( children: [ - Text(text, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w500)), + Text( + text, + style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w500), + ), const Spacer(), SvgPicture.asset(icon), ], @@ -1185,7 +1642,11 @@ class _ProfileScreenState extends State { enabled: false, height: 1, padding: EdgeInsets.zero, - child: Container(margin: const EdgeInsets.symmetric(horizontal: 12), height: 1, color: appTheme.dividerColor), + child: Container( + margin: const EdgeInsets.symmetric(horizontal: 12), + height: 1, + color: appTheme.dividerColor, + ), ); } @@ -1194,11 +1655,19 @@ class _ProfileScreenState extends State { children: [ Text( title, - style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w500, color: Color(0xFF6D6F71)), + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w500, + color: Color(0xFF6D6F71), + ), ), Text( value, - style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w500, color: Color(0xFF323F4B)), + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w500, + color: Color(0xFF323F4B), + ), ), ], ); @@ -1209,13 +1678,20 @@ class _ProfileScreenState extends State { children: [ const SizedBox(width: 16), GestureDetector( - onTap: () async => await profileCubit.getChatRoomIdBySlug(slug: widget.slug!), + onTap: () async => + await profileCubit.getChatRoomIdBySlug(slug: widget.slug!), child: Container( alignment: Alignment.center, padding: const EdgeInsets.all(8), width: (MediaQuery.of(context).size.width - 40) / 2, - decoration: BoxDecoration(color: appTheme.blueA400.withValues(alpha: 0.1), borderRadius: BorderRadius.circular(16)), - child: Text('Сообщение', style: TextStyle(color: appTheme.blueA400)), + decoration: BoxDecoration( + color: appTheme.blueA400.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(16), + ), + child: Text( + 'Сообщение', + style: TextStyle(color: appTheme.blueA400), + ), ), ), const SizedBox(width: 8), @@ -1224,14 +1700,22 @@ class _ProfileScreenState extends State { setState(() { canSub = !canSub; }); - canSub ? sl().unsubscribe(state.profile.slug) : sl().subscribe(state.profile.slug); + canSub + ? sl().unsubscribe(state.profile.slug) + : sl().subscribe(state.profile.slug); }, child: Container( alignment: Alignment.center, padding: const EdgeInsets.all(8), width: (MediaQuery.of(context).size.width - 40) / 2, - decoration: BoxDecoration(color: !canSub ? Colors.grey.shade200 : appTheme.blueA400, borderRadius: BorderRadius.circular(16)), - child: Text(!canSub ? 'Отписаться' : 'Подписаться', style: TextStyle(color: !canSub ? Colors.black : Colors.white)), + decoration: BoxDecoration( + color: !canSub ? Colors.grey.shade200 : appTheme.blueA400, + borderRadius: BorderRadius.circular(16), + ), + child: Text( + !canSub ? 'Отписаться' : 'Подписаться', + style: TextStyle(color: !canSub ? Colors.black : Colors.white), + ), ), ), const SizedBox(width: 16), @@ -1250,15 +1734,29 @@ class _ProfileScreenState extends State { SingleChildScrollView( padding: const EdgeInsets.symmetric(horizontal: 16), scrollDirection: Axis.horizontal, - child: Row(children: [_choiceChipWidget(0), const SizedBox(width: 8), _choiceChipWidget(1), const SizedBox(width: 8), _choiceChipWidget(2)]), + child: Row( + children: [ + _choiceChipWidget(0), + const SizedBox(width: 8), + _choiceChipWidget(1), + const SizedBox(width: 8), + _choiceChipWidget(2), + ], + ), ), Builder( builder: (context) { switch (_selectedChipIndex) { case 0: - return WeightChartWidget(weightPoints: weightPoints, profileCubit: profileCubit); + return WeightChartWidget( + weightPoints: weightPoints, + profileCubit: profileCubit, + ); case 1: - return CircumferenceChartWidget(circumferencePoints: circumferencePoints, profileCubit: profileCubit); + return CircumferenceChartWidget( + circumferencePoints: circumferencePoints, + profileCubit: profileCubit, + ); case 2: return ImtChartWidget(weightPoints: weightPoints); default: @@ -1296,7 +1794,11 @@ class _ProfileScreenState extends State { shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), label: Text(chipsIntervalLabelList[index]), - labelStyle: TextStyle(color: appTheme.gray600, fontWeight: FontWeight.w500, fontSize: 12), + labelStyle: TextStyle( + color: appTheme.gray600, + fontWeight: FontWeight.w500, + fontSize: 12, + ), selected: _selectedIntervalChipIndex == index, onSelected: (bool selected) { profileCubit.getMyProfile(period: chipsIntervalList[index]); @@ -1316,7 +1818,11 @@ class _ProfileScreenState extends State { shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), label: Text(chipsLabelList[index]), - labelStyle: TextStyle(color: index == _selectedChipIndex ? Colors.white : appTheme.gray600, fontWeight: FontWeight.w500, fontSize: 12), + labelStyle: TextStyle( + color: index == _selectedChipIndex ? Colors.white : appTheme.gray600, + fontWeight: FontWeight.w500, + fontSize: 12, + ), selected: _selectedChipIndex == index, backgroundColor: appTheme.gray100, onSelected: (bool selected) { diff --git a/lib/features/profile/presentation/widgets/sub_widget.dart b/lib/features/profile/presentation/widgets/sub_widget.dart index 48f11153..2423087c 100644 --- a/lib/features/profile/presentation/widgets/sub_widget.dart +++ b/lib/features/profile/presentation/widgets/sub_widget.dart @@ -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 createState() => _SubWidgetState(); @@ -21,16 +19,20 @@ class _SubWidgetState extends State { @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().baseUrl + widget.subModel.profilePhotoUrl), + foregroundImage: NetworkImage( + joinBaseUrl( + sl().baseUrl, + widget.subModel.profilePhotoUrl, + ), + ), backgroundImage: const AssetImage('assets/images/no_avatar.png'), radius: 28, ), @@ -62,7 +64,9 @@ class _SubWidgetState extends State { ), 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, diff --git a/pubspec.yaml b/pubspec.yaml index e5a30e54..c3102218 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: eatvio_app description: A new Flutter project. -version: 1.3.49+138 +version: 1.3.50+139 publish_to: 'none' environment: diff --git a/test/core/error_reporting/error_report_policy_test.dart b/test/core/error_reporting/error_report_policy_test.dart index ee84f765..b8a0e03d 100644 --- a/test/core/error_reporting/error_report_policy_test.dart +++ b/test/core/error_reporting/error_report_policy_test.dart @@ -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'), diff --git a/test/core/push/hms_client_error_test.dart b/test/core/push/hms_client_error_test.dart new file mode 100644 index 00000000..e19f308e --- /dev/null +++ b/test/core/push/hms_client_error_test.dart @@ -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', + ); + }); + }); +} diff --git a/test/core/utils/url_utils_test.dart b/test/core/utils/url_utils_test.dart new file mode 100644 index 00000000..2b757ef1 --- /dev/null +++ b/test/core/utils/url_utils_test.dart @@ -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', + ); + }); +} diff --git a/test/features/blog_post_create/presentation/pages/blog_post_create_screen_test.dart b/test/features/blog_post_create/presentation/pages/blog_post_create_screen_test.dart index 1983335a..cd17183e 100644 --- a/test/features/blog_post_create/presentation/pages/blog_post_create_screen_test.dart +++ b/test/features/blog_post_create/presentation/pages/blog_post_create_screen_test.dart @@ -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( diff --git a/test/features/menu/presentation/bloc/notification_settings_cubit/notification_settings_state_test.dart b/test/features/menu/presentation/bloc/notification_settings_cubit/notification_settings_state_test.dart index 6fb9b6ec..93e11cc4 100644 --- a/test/features/menu/presentation/bloc/notification_settings_cubit/notification_settings_state_test.dart +++ b/test/features/menu/presentation/bloc/notification_settings_cubit/notification_settings_state_test.dart @@ -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); }); diff --git a/test/features/menu/presentation/bloc/notification_settings_cubit_test.dart b/test/features/menu/presentation/bloc/notification_settings_cubit_test.dart index 4fc65ffb..00d9967f 100644 --- a/test/features/menu/presentation/bloc/notification_settings_cubit_test.dart +++ b/test/features/menu/presentation/bloc/notification_settings_cubit_test.dart @@ -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()); + 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); + }); }); } diff --git a/test/features/menu/presentation/notification_settings_user_message_test.dart b/test/features/menu/presentation/notification_settings_user_message_test.dart new file mode 100644 index 00000000..95cd7ce0 --- /dev/null +++ b/test/features/menu/presentation/notification_settings_user_message_test.dart @@ -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, + ); + }); + }); +} diff --git a/test/features/menu/presentation/pages/notifications_settings_screen_smoke_test.dart b/test/features/menu/presentation/pages/notifications_settings_screen_smoke_test.dart index 08d3b522..9ee07d14 100644 --- a/test/features/menu/presentation/pages/notifications_settings_screen_smoke_test.dart +++ b/test/features/menu/presentation/pages/notifications_settings_screen_smoke_test.dart @@ -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(() => repository); + sl.registerLazySingleton(() => 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); + }); }); }