feat(push_notifications): issue-660, fix: issue-908

This commit is contained in:
misha
2026-10-14 08:16:20 +03:00
parent 98c175df44
commit dcd343e765
29 changed files with 2281 additions and 608 deletions

View File

@@ -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)
}
}
}
}

View File

@@ -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);

View File

@@ -4,14 +4,13 @@ import 'dart:io';
import 'package:eatvio_app/core/app_config.dart';
import 'package:eatvio_app/core/app_initializer.dart';
import 'package:eatvio_app/core/notifications/notification_permission_requester.dart';
import 'package:eatvio_app/core/utils/toasts.dart';
import 'package:flutter/services.dart';
import 'package:eatvio_app/core/push/hms_client_error.dart';
import 'package:device_info_plus/device_info_plus.dart';
import 'package:dio/dio.dart';
import 'package:eatvio_app/core/locator_service.dart';
import 'package:eatvio_app/core/token_storage.dart';
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter/services.dart' show MethodChannel, PlatformException;
import 'package:flutter/services.dart';
import 'package:eatvio_app/core/error_reporting/error_reporter.dart';
import 'package:flutter_callkit_incoming/flutter_callkit_incoming.dart';
@@ -27,20 +26,8 @@ class FirebaseApi {
_notificationPermissionRequester = notificationPermissionRequester ?? NotificationPermissionRequester();
Future<String?> getToken() async {
String? token;
if (await _isHuaweiDevice()) {
token = await _getHuaweiPushTokenNative().timeout(
const Duration(seconds: 5),
onTimeout: () => null,
);
} else {
token = await _firebaseMessaging.getToken().timeout(
const Duration(seconds: 5),
onTimeout: () => null,
);
}
return token;
final credentials = await _resolvePushCredentials();
return credentials.token;
}
Future<void> _requestNotificationPermission() async {
@@ -58,12 +45,15 @@ class FirebaseApi {
try {
await _requestNotificationPermission();
final deviceInfo = DeviceInfoPlugin();
final credentials = await _resolvePushCredentials();
final token = credentials.token;
if (token == null || token.isEmpty) return;
await sl<Dio>().post(
'/api/anonymous_token',
data: {
'platform': Platform.isIOS ? 'ios' : (await _isHuaweiDevice()) ? 'huawei' : 'android',
'platform': credentials.platform,
'hw_id': Platform.isIOS ? (await deviceInfo.iosInfo).identifierForVendor : (await deviceInfo.androidInfo).id,
'token': await getToken(),
'token': token,
},
).timeout(
const Duration(seconds: 10),
@@ -83,13 +73,15 @@ class FirebaseApi {
try {
await _requestNotificationPermission();
final deviceInfo = DeviceInfoPlugin();
final isHuawei = Platform.isAndroid ? await _isHuaweiDevice() : false;
final credentials = await _resolvePushCredentials();
final token = credentials.token;
if (token == null || token.isEmpty) return;
await sl<Dio>().post(
'/api/profile/notification/token/register',
data: {
'platform': Platform.isIOS ? 'ios' : (isHuawei ? 'huawei' : 'android'),
'platform': credentials.platform,
'hw_id': Platform.isIOS ? (await deviceInfo.iosInfo).identifierForVendor : (await deviceInfo.androidInfo).id,
'token': await getToken(),
'token': token,
},
).timeout(
const Duration(seconds: 10),
@@ -124,13 +116,13 @@ class FirebaseApi {
Future<void> unregisterPushToken() async {
if (!await TokenStorage.haveTokenInStorage()) return;
try {
final isHuawei = Platform.isAndroid ? await _isHuaweiDevice() : false;
final platform = Platform.isIOS ? 'ios' : (isHuawei ? 'huawei' : 'android');
final token = (await getToken()) ?? '';
final credentials = await _resolvePushCredentials();
final token = credentials.token ?? '';
if (token.isEmpty) return;
await sl<Dio>().post(
'/api/profile/notification/token/unregister',
data: {
'platform': platform,
'platform': credentials.platform,
'token': token,
},
).timeout(
@@ -190,30 +182,74 @@ class FirebaseApi {
}
}
Future<({String? token, String platform})> _resolvePushCredentials() async {
if (Platform.isIOS) {
return (
token: await _getFcmToken(),
platform: pushPlatform(isIos: true, huaweiFamily: false, hmsAvailable: false, hasHmsToken: false),
);
}
final huaweiFamily = await _isHuaweiDevice();
if (huaweiFamily && await _isHmsAvailable()) {
final token = await _getHuaweiPushTokenNative().timeout(
const Duration(seconds: 5),
onTimeout: () => null,
);
if (token != null && token.isNotEmpty) {
return (
token: token,
platform: pushPlatform(isIos: false, huaweiFamily: true, hmsAvailable: true, hasHmsToken: true),
);
}
}
return (
token: await _getFcmToken(),
platform: pushPlatform(isIos: false, huaweiFamily: huaweiFamily, hmsAvailable: false, hasHmsToken: false),
);
}
Future<String?> _getFcmToken() {
return _firebaseMessaging.getToken().timeout(
const Duration(seconds: 5),
onTimeout: () => null,
);
}
Future<bool> _isHuaweiDevice() async {
if (!Platform.isAndroid) return false;
try {
final info = await DeviceInfoPlugin().androidInfo;
final brand = info.brand.toLowerCase();
final manufacturer = info.manufacturer.toLowerCase();
return brand.contains('huawei') || brand.contains('honor') || manufacturer.contains('huawei') || manufacturer.contains('honor');
return isHuaweiFamilyBrand(brand: info.brand, manufacturer: info.manufacturer);
} catch (_) {
return false;
}
}
Future<bool> _isHmsAvailable() async {
try {
const channel = MethodChannel('com.eatvioapp/hms_push');
final available = await channel.invokeMethod<bool>('isHmsAvailable').timeout(const Duration(seconds: 5));
return available ?? false;
} catch (e, st) {
log('HMS availability check failed: $e', stackTrace: st);
return false;
}
}
Future<String?> _getHuaweiPushTokenNative() async {
try {
const channel = MethodChannel('com.eatvioapp/hms_push');
final String? token = await channel.invokeMethod<String>('getHuaweiToken');
return token;
} on PlatformException catch (e, st) {
showErrorToast('$e $st');
log(e.toString(), stackTrace: st);
await _errorReporter.report(e, st, origin: 'FirebaseApi');
if (!isHmsClientUnavailable(e)) {
await _errorReporter.report(e, st, origin: 'FirebaseApi');
}
return null;
} catch (e, st) {
showErrorToast('$e $st');
log(e.toString(), stackTrace: st);
await _errorReporter.report(e, st, origin: 'FirebaseApi');
return null;

View File

@@ -0,0 +1,36 @@
import 'package:flutter/services.dart';
bool isHuaweiFamilyBrand({
required String brand,
required String manufacturer,
}) {
final normalizedBrand = brand.toLowerCase();
final normalizedManufacturer = manufacturer.toLowerCase();
return normalizedBrand.contains('huawei') ||
normalizedBrand.contains('honor') ||
normalizedManufacturer.contains('huawei') ||
normalizedManufacturer.contains('honor');
}
/// HMS Core is missing or rejects this app.
///
/// Honor devices with Google services return `907135003: client api invalid`.
/// That is a device capability, not an app crash: the caller should use FCM.
bool isHmsClientUnavailable(Object error) {
if (error is! PlatformException) return false;
final message = '${error.message ?? ''} ${error.details ?? ''}'.toLowerCase();
return message.contains('907135003') ||
message.contains('client api invalid');
}
/// `huawei` only when an HMS token was actually issued. Otherwise Android uses FCM.
String pushPlatform({
required bool isIos,
required bool huaweiFamily,
required bool hmsAvailable,
required bool hasHmsToken,
}) {
if (isIos) return 'ios';
if (huaweiFamily && hmsAvailable && hasHmsToken) return 'huawei';
return 'android';
}

View File

@@ -0,0 +1,11 @@
/// Joins an API media path with [baseUrl].
///
/// Uploaded photos come back as relative paths (`/storage/...`).
/// Default avatars are already absolute (`https://ui-avatars...`) and must stay as-is.
String joinBaseUrl(String baseUrl, String path) {
final normalized = path.toLowerCase();
if (normalized.startsWith('https://') || normalized.startsWith('http://')) {
return path;
}
return '$baseUrl$path';
}

View File

@@ -2,6 +2,7 @@ import 'package:dismissible_page/dismissible_page.dart';
import 'package:eatvio_app/core/app_config.dart';
import 'package:eatvio_app/core/enums/complained_content.dart';
import 'package:eatvio_app/core/utils/date_time_utils.dart';
import 'package:eatvio_app/core/utils/url_utils.dart';
import 'package:eatvio_app/core/widgets/custom_popup.dart';
import 'package:eatvio_app/core/widgets/loader.dart';
import 'package:eatvio_app/core/widgets/markdown_html_widget.dart';
@@ -26,7 +27,11 @@ class BlogPostScreen extends StatefulWidget {
final int id;
final int? highlightedCommentId;
const BlogPostScreen({super.key, required this.id, this.highlightedCommentId});
const BlogPostScreen({
super.key,
required this.id,
this.highlightedCommentId,
});
@override
State<BlogPostScreen> createState() => _BlogPostScreenState();
@@ -45,7 +50,10 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
}
void _openGallery(List<String> imageUrls, int index, {Object? heroTag}) {
context.pushTransparentRoute(rootNavigator: true, PhotoViewScreen(imageUrls: imageUrls, index: index, heroTag: heroTag));
context.pushTransparentRoute(
rootNavigator: true,
PhotoViewScreen(imageUrls: imageUrls, index: index, heroTag: heroTag),
);
}
@override
@@ -53,7 +61,8 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
return BlocProvider(
create: (context) => sl<BlogPostCubit>()..getBlogPost(widget.id),
child: BlocProvider<CommentsCubit>(
create: (BuildContext context) => commentsCubit..getComments(widget.id.toString(), CommentSourceType.blogPost, true),
create: (BuildContext context) => commentsCubit
..getComments(widget.id.toString(), CommentSourceType.blogPost, true),
child: BlocBuilder<BlogPostCubit, BlogPostState>(
builder: (context, blogPostState) {
return BlocConsumer<CommentsCubit, CommentsState>(
@@ -81,15 +90,23 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
body: const Loader(),
);
}
final bool isLiked = blogPostState.blogPostModel?.wasLikedByMe ?? false;
final int totalLikes = blogPostState.blogPostModel?.totalLikes ?? 0;
final bool isLiked =
blogPostState.blogPostModel?.wasLikedByMe ?? false;
final int totalLikes =
blogPostState.blogPostModel?.totalLikes ?? 0;
return Scaffold(
resizeToAvoidBottomInset: true,
bottomSheet: Container(
padding: EdgeInsets.only(bottom: MediaQuery.of(context).padding.bottom + 8),
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).padding.bottom + 8,
),
decoration: BoxDecoration(
color: Colors.white,
border: Border(top: BorderSide(color: Colors.black.withValues(alpha: 0.1))),
border: Border(
top: BorderSide(
color: Colors.black.withValues(alpha: 0.1),
),
),
),
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
@@ -107,10 +124,17 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
FocusManager.instance.primaryFocus?.unfocus();
},
child: Container(
decoration: BoxDecoration(color: appTheme.blueA400, shape: BoxShape.circle),
decoration: BoxDecoration(
color: appTheme.blueA400,
shape: BoxShape.circle,
),
height: 32,
width: 32,
child: const Icon(Icons.close_rounded, size: 18, color: Colors.white),
child: const Icon(
Icons.close_rounded,
size: 18,
color: Colors.white,
),
),
),
if (replyId != null) const SizedBox(width: 8),
@@ -118,7 +142,8 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
child: ConstrainedBox(
constraints: const BoxConstraints(minHeight: 52),
child: TextFormField(
textCapitalization: TextCapitalization.sentences,
textCapitalization:
TextCapitalization.sentences,
onChanged: (_) {
setState(() {});
},
@@ -126,26 +151,48 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
maxLines: 5,
minLines: 1,
keyboardType: TextInputType.multiline,
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w400, color: Colors.black, height: 1.2),
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w400,
color: Colors.black,
height: 1.2,
),
decoration: InputDecoration(
isDense: false,
// убираем true, чтобы padding учитывался корректно
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 16,
),
// вертикальный паддинг увеличен
hintText: S.of(context).comment_hint,
hintStyle: const TextStyle(fontSize: 16, fontWeight: FontWeight.w400, height: 1.2, color: Color(0xFFAEAEB2)),
hintStyle: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w400,
height: 1.2,
color: Color(0xFFAEAEB2),
),
filled: true,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: const BorderSide(color: Color(0xFFD1D1D6), width: 1),
borderSide: const BorderSide(
color: Color(0xFFD1D1D6),
width: 1,
),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: const BorderSide(color: Color(0xFFD1D1D6), width: 1),
borderSide: const BorderSide(
color: Color(0xFFD1D1D6),
width: 1,
),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: const BorderSide(color: Color(0xFFD1D1D6), width: 1),
borderSide: const BorderSide(
color: Color(0xFFD1D1D6),
width: 1,
),
),
),
),
@@ -163,7 +210,10 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
);
if (!context.mounted) return;
if (isSent) context.read<FeedCubit>().incrementCommentsState(widget.id);
if (isSent)
context
.read<FeedCubit>()
.incrementCommentsState(widget.id);
context.read<CommentsCubit>().setReplyId(null);
setState(() {
@@ -174,10 +224,19 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
}
},
child: Container(
decoration: BoxDecoration(color: textEditingController.text.length > 2 ? appTheme.blueA400 : Colors.grey, shape: BoxShape.circle),
decoration: BoxDecoration(
color: textEditingController.text.length > 2
? appTheme.blueA400
: Colors.grey,
shape: BoxShape.circle,
),
height: 32,
width: 32,
child: const Icon(Icons.send_rounded, size: 18, color: Colors.white),
child: const Icon(
Icons.send_rounded,
size: 18,
color: Colors.white,
),
),
),
],
@@ -197,24 +256,36 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
),
actions: [
CustomPopup(
isAuthor: blogPostState.blogPostModel?.author.authorIsMe ?? false,
isAuthorAdmin: blogPostState.blogPostModel?.author.isAdmin ?? false,
isAuthor:
blogPostState.blogPostModel?.author.authorIsMe ??
false,
isAuthorAdmin:
blogPostState.blogPostModel?.author.isAdmin ??
false,
complainedContentType: ComplainedContent.posts,
path: 'blog_post/${(widget.id)}',
id: widget.id,
feedBlogPostModel: blogPostState.blogPostModel,
userSlug: blogPostState.blogPostModel?.author.authorSlug ?? '',
userSlug:
blogPostState.blogPostModel?.author.authorSlug ??
'',
),
const SizedBox(width: 16),
],
),
body: SingleChildScrollView(
padding: EdgeInsets.only(bottom: 160 + MediaQuery.of(context).padding.bottom), // поле комментария (52-200) + padding (12+12+56) + safe area
padding: EdgeInsets.only(
bottom: 160 + MediaQuery.of(context).padding.bottom,
), // поле комментария (52-200) + padding (12+12+56) + safe area
physics: const BouncingScrollPhysics(),
child: Column(
children: [
GestureDetector(
onTap: () => GoRouter.of(context).pushNamed('profileScreen', extra: blogPostState.blogPostModel?.author.authorSlug),
onTap: () => GoRouter.of(context).pushNamed(
'profileScreen',
extra:
blogPostState.blogPostModel?.author.authorSlug,
),
child: Ink(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
@@ -223,21 +294,48 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
children: [
CircleAvatar(
radius: 16,
foregroundImage: NetworkImage(sl<AppConfig>().baseUrl + (blogPostState.blogPostModel?.author.authorProfileIcon ?? '')),
backgroundImage: const AssetImage('assets/images/no_avatar.png'),
foregroundImage: NetworkImage(
joinBaseUrl(
sl<AppConfig>().baseUrl,
blogPostState
.blogPostModel
?.author
.authorProfileIcon ??
'',
),
),
backgroundImage: const AssetImage(
'assets/images/no_avatar.png',
),
),
const SizedBox(width: 8),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
blogPostState.blogPostModel?.author.authorName ?? '',
style: const TextStyle(color: Colors.black, fontSize: 14, fontWeight: FontWeight.w400, height: 1),
blogPostState
.blogPostModel
?.author
.authorName ??
'',
style: const TextStyle(
color: Colors.black,
fontSize: 14,
fontWeight: FontWeight.w400,
height: 1,
),
),
const SizedBox(height: 2),
Text(
(blogPostState.blogPostModel?.createdAt ?? DateTime.now()).format('dd MMMM', 'ru'),
style: TextStyle(color: Colors.grey.shade600, fontSize: 12, fontWeight: FontWeight.w400, height: 1),
(blogPostState.blogPostModel?.createdAt ??
DateTime.now())
.format('dd MMMM', 'ru'),
style: TextStyle(
color: Colors.grey.shade600,
fontSize: 12,
fontWeight: FontWeight.w400,
height: 1,
),
),
],
),
@@ -245,10 +343,20 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
),
),
),
if (blogPostState.blogPostModel?.description.isNotEmpty ?? false)
if (blogPostState
.blogPostModel
?.description
.isNotEmpty ??
false)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: MarkdownHtmlWidget(markdownText: blogPostState.blogPostModel!.description),
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
),
child: MarkdownHtmlWidget(
markdownText:
blogPostState.blogPostModel!.description,
),
),
const SizedBox(height: 8),
Builder(
@@ -257,16 +365,31 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
if (post == null) {
return const SizedBox.shrink();
}
final imageUrl = PostFeedMapper.resolveMediaUrl(post.image);
final imageUrl = PostFeedMapper.resolveMediaUrl(
post.image,
);
final embedUrl = post.videoEmbedUrl?.trim();
return BlogPostMediaContent(
galleryImages: post.images,
imageUrl: imageUrl,
videoEmbedUrl: embedUrl != null && embedUrl.isNotEmpty ? embedUrl : null,
videoUrlThumb: post.videoUrlThumb != null ? PostFeedMapper.resolveMediaUrl(post.videoUrlThumb) : null,
videoEmbedUrl:
embedUrl != null && embedUrl.isNotEmpty
? embedUrl
: null,
videoUrlThumb: post.videoUrlThumb != null
? PostFeedMapper.resolveMediaUrl(
post.videoUrlThumb,
)
: null,
currentGalleryIndex: currentGalleryIndex,
heroTag: 'blog-post-screen-${post.id}-cover',
onOpenGallery: (urls, index) => _openGallery(urls, index, heroTag: urls.length == 1 ? 'blog-post-screen-${post.id}-cover' : null),
onOpenGallery: (urls, index) => _openGallery(
urls,
index,
heroTag: urls.length == 1
? 'blog-post-screen-${post.id}-cover'
: null,
),
onGalleryChanged: (index) {
setState(() {
currentGalleryIndex = index;
@@ -276,22 +399,43 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
},
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
child: Row(
children: [
_iconButton(
icon: isLiked ? Icons.favorite_rounded : Icons.favorite_border_rounded,
icon: isLiked
? Icons.favorite_rounded
: Icons.favorite_border_rounded,
text: totalLikes.toString(),
iconColor: isLiked ? Colors.red.withValues(alpha: 0.8) : null,
backgroundColor: isLiked ? Colors.red.withValues(alpha: 0.1) : null,
textColor: isLiked ? Colors.red.withValues(alpha: 0.8) : null,
iconColor: isLiked
? Colors.red.withValues(alpha: 0.8)
: null,
backgroundColor: isLiked
? Colors.red.withValues(alpha: 0.1)
: null,
textColor: isLiked
? Colors.red.withValues(alpha: 0.8)
: null,
onTap: () async {
await context.read<BlogPostCubit>().toggleLike();
await context
.read<BlogPostCubit>()
.toggleLike();
// ignore: use_build_context_synchronously
final updated = context.read<BlogPostCubit>().state.blogPostModel;
final updated = context
.read<BlogPostCubit>()
.state
.blogPostModel;
if (updated != null) {
// ignore: use_build_context_synchronously
context.read<FeedCubit>().updateFeedBlogPostList(updated, updated.wasLikedByMe);
context
.read<FeedCubit>()
.updateFeedBlogPostList(
updated,
updated.wasLikedByMe,
);
}
},
),
@@ -299,21 +443,36 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
_iconButton(
icon: Icons.reply_rounded,
onTap: () {
final box = context.findRenderObject() as RenderBox?;
final box =
context.findRenderObject() as RenderBox?;
SharePlus.instance.share(
ShareParams(
uri: Uri.parse('${sl<AppConfig>().baseUrl}/ru/blog_post/${widget.id}'),
sharePositionOrigin: box!.localToGlobal(Offset.zero) & box.size,
uri: Uri.parse(
'${sl<AppConfig>().baseUrl}/ru/blog_post/${widget.id}',
),
sharePositionOrigin:
box!.localToGlobal(Offset.zero) &
box.size,
),
);
},
),
const Spacer(),
Icon(Icons.visibility_rounded, size: 16, color: appTheme.gray500),
Icon(
Icons.visibility_rounded,
size: 16,
color: appTheme.gray500,
),
const SizedBox(width: 4),
Text(
blogPostState.blogPostModel?.totalViews.toString() ?? '',
style: TextStyle(fontSize: 12, color: appTheme.gray500, fontWeight: FontWeight.w400),
blogPostState.blogPostModel?.totalViews
.toString() ??
'',
style: TextStyle(
fontSize: 12,
color: appTheme.gray500,
fontWeight: FontWeight.w400,
),
),
],
),
@@ -321,7 +480,10 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
Container(
width: MediaQuery.of(context).size.width - 32,
height: 0.4,
decoration: BoxDecoration(color: Colors.black.withValues(alpha: 0.2), borderRadius: BorderRadius.circular(16)),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(16),
),
),
const SizedBox(height: 8),
Container(
@@ -329,7 +491,11 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
alignment: Alignment.centerLeft,
child: Text(
'${blogPostState.blogPostModel?.totalComments} КОММЕНТАРИЕВ',
style: TextStyle(fontSize: 12, color: appTheme.gray500, fontWeight: FontWeight.w400),
style: TextStyle(
fontSize: 12,
color: appTheme.gray500,
fontWeight: FontWeight.w400,
),
),
),
const SizedBox(height: 16),
@@ -354,13 +520,23 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
);
}
InkWell _iconButton({required IconData icon, String? text, Function()? onTap, Color? iconColor, Color? backgroundColor, Color? textColor}) {
InkWell _iconButton({
required IconData icon,
String? text,
Function()? onTap,
Color? iconColor,
Color? backgroundColor,
Color? textColor,
}) {
return InkWell(
onTap: onTap ?? () {},
borderRadius: BorderRadius.circular(16),
child: Ink(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
decoration: BoxDecoration(color: backgroundColor ?? appTheme.gray100, borderRadius: BorderRadius.circular(16)),
decoration: BoxDecoration(
color: backgroundColor ?? appTheme.gray100,
borderRadius: BorderRadius.circular(16),
),
height: 32,
child: Row(
mainAxisSize: MainAxisSize.min,
@@ -370,7 +546,10 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
if (text != null)
Text(
text,
style: TextStyle(color: textColor ?? appTheme.gray600, fontWeight: FontWeight.w500),
style: TextStyle(
color: textColor ?? appTheme.gray600,
fontWeight: FontWeight.w500,
),
),
],
),

View File

@@ -674,163 +674,185 @@ class _BlogPostCreateScreenState extends State<BlogPostCreateScreen> {
);
}
Scaffold _buildBlogPostCreate(BuildContext context) {
return Scaffold(
resizeToAvoidBottomInset: true,
floatingActionButtonLocation: FloatingActionButtonLocation.centerFloat,
floatingActionButton: enableMdPanel || isPreviewMode
? Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (!isPreviewMode) ...[
Container(
width: MediaQuery.of(context).size.width - 76,
height: 52,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(20),
boxShadow: [
BoxShadow(
color: const Color(0x00000000).withValues(alpha: 0.2),
spreadRadius: 0,
blurRadius: 32,
offset: const Offset(0, 0),
),
],
),
child: Row(
children: [
const SizedBox(width: 8),
_mdTextButton('H', _applyHeading),
const SizedBox(width: 8),
_mdButton(Icons.format_bold, _applyBold),
const SizedBox(width: 8),
_mdButton(Icons.format_italic, _applyItalic),
const SizedBox(width: 8),
_mdButton(
Icons.format_list_numbered,
_applyNumberedList,
),
const SizedBox(width: 8),
_mdButton(
Icons.format_list_bulleted,
_applyBulletedList,
),
const SizedBox(width: 8),
_mdButton(Icons.link, _applyLink),
const SizedBox(width: 8),
_mdButton(Icons.horizontal_rule, _applyHorizontalRule),
const SizedBox(width: 8),
],
),
),
const SizedBox(width: 8),
],
Container(
width: 52,
height: 52,
padding: const EdgeInsets.symmetric(horizontal: 8),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(20),
boxShadow: [
BoxShadow(
color: const Color(0x00000000).withValues(alpha: 0.2),
spreadRadius: 0,
blurRadius: 32,
offset: const Offset(0, 0),
),
],
),
child: Row(
children: [
_mdButton(
isPreviewMode ? Icons.edit : Icons.visibility,
_togglePreview,
),
],
),
Widget _buildEditorToolbar(BuildContext context) {
return Row(
key: const ValueKey('blogPostCreateEditorToolbar'),
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (!isPreviewMode) ...[
Container(
width: MediaQuery.of(context).size.width - 76,
height: 52,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(20),
boxShadow: [
BoxShadow(
color: const Color(0x00000000).withValues(alpha: 0.2),
spreadRadius: 0,
blurRadius: 32,
offset: const Offset(0, 0),
),
],
)
: null,
),
child: Row(
children: [
const SizedBox(width: 8),
_mdTextButton('H', _applyHeading),
const SizedBox(width: 8),
_mdButton(Icons.format_bold, _applyBold),
const SizedBox(width: 8),
_mdButton(Icons.format_italic, _applyItalic),
const SizedBox(width: 8),
_mdButton(Icons.format_list_numbered, _applyNumberedList),
const SizedBox(width: 8),
_mdButton(Icons.format_list_bulleted, _applyBulletedList),
const SizedBox(width: 8),
_mdButton(Icons.link, _applyLink),
const SizedBox(width: 8),
_mdButton(Icons.horizontal_rule, _applyHorizontalRule),
const SizedBox(width: 8),
],
),
),
const SizedBox(width: 8),
],
Container(
width: 52,
height: 52,
padding: const EdgeInsets.symmetric(horizontal: 8),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(20),
boxShadow: [
BoxShadow(
color: const Color(0x00000000).withValues(alpha: 0.2),
spreadRadius: 0,
blurRadius: 32,
offset: const Offset(0, 0),
),
],
),
child: Row(
children: [
_mdButton(
isPreviewMode ? Icons.edit : Icons.visibility,
_togglePreview,
),
],
),
),
],
);
}
Scaffold _buildBlogPostCreate(BuildContext context) {
final editorToolbarVisible = enableMdPanel || isPreviewMode;
return Scaffold(
resizeToAvoidBottomInset: true,
appBar: AppBar(),
body: BlocBuilder<BlogPostCreateCubit, BlogPostCreateState>(
bloc: blogPostCubit,
builder: (context, state) {
return SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Заголовок'),
const SizedBox(height: 8),
CustomTextFormField(
key: AppKeys.blogPostCreateScreenTitleTextField,
controller: titleController,
hasError: state.titleError != null,
onChanged: (_) => blogPostCubit.clearTitleError(),
),
if (state.titleError != null) ...[
const SizedBox(height: 4),
ValidationErrorWidget(error: state.titleError!),
],
const SizedBox(height: 16),
const Text('Описание'),
const SizedBox(height: 8),
isPreviewMode
? _buildMarkdownPreview()
: CustomTextFormField(
key: AppKeys.blogPostCreateScreenContentTextField,
controller: descriptionController,
focusNode: descriptionFocusNode,
maxLines: 6,
minLines: 4,
hasError: state.descriptionError != null,
onChanged: (_) => blogPostCubit.clearDescriptionError(),
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Заголовок'),
const SizedBox(height: 8),
CustomTextFormField(
key: AppKeys.blogPostCreateScreenTitleTextField,
controller: titleController,
hasError: state.titleError != null,
onChanged: (_) => blogPostCubit.clearTitleError(),
),
if (state.descriptionError != null) ...[
const SizedBox(height: 4),
ValidationErrorWidget(error: state.descriptionError!),
],
const SizedBox(height: 16),
Text(
'Изображения ($_galleryCount/${BlogPostRepository.maxGalleryImages})',
if (state.titleError != null) ...[
const SizedBox(height: 4),
ValidationErrorWidget(error: state.titleError!),
],
const SizedBox(height: 16),
const Text('Описание'),
const SizedBox(height: 8),
isPreviewMode
? _buildMarkdownPreview()
: CustomTextFormField(
key: AppKeys.blogPostCreateScreenContentTextField,
controller: descriptionController,
focusNode: descriptionFocusNode,
maxLines: 6,
minLines: 4,
hasError: state.descriptionError != null,
onChanged: (_) =>
blogPostCubit.clearDescriptionError(),
),
if (state.descriptionError != null) ...[
const SizedBox(height: 4),
ValidationErrorWidget(error: state.descriptionError!),
],
const SizedBox(height: 16),
Text(
'Изображения ($_galleryCount/${BlogPostRepository.maxGalleryImages})',
),
const SizedBox(height: 8),
_buildGallery(),
if (state.imagesError != null) ...[
const SizedBox(height: 4),
ValidationErrorWidget(error: state.imagesError!),
],
const SizedBox(height: 16),
const Text('Ссылка на видео'),
const SizedBox(height: 4),
Text(
'Rutube, VK или OK',
style: TextStyle(
fontSize: 13,
color: appTheme.blueGray300,
),
),
const SizedBox(height: 8),
CustomTextFormField(
key: AppKeys.blogPostCreateScreenVideoLinkTextField,
controller: videoUrlController,
hasError: state.videoUrlError != null,
keyboardType: TextInputType.url,
onChanged: (_) => blogPostCubit.clearVideoUrlError(),
),
if (state.videoUrlError != null) ...[
const SizedBox(height: 4),
ValidationErrorWidget(error: state.videoUrlError!),
],
],
),
),
),
if (editorToolbarVisible) ...[
const SizedBox(height: 8),
_buildGallery(),
if (state.imagesError != null) ...[
const SizedBox(height: 4),
ValidationErrorWidget(error: state.imagesError!),
],
const SizedBox(height: 16),
const Text('Ссылка на видео'),
const SizedBox(height: 4),
Text(
'Rutube, VK или OK',
style: TextStyle(fontSize: 13, color: appTheme.blueGray300),
),
const SizedBox(height: 8),
CustomTextFormField(
key: AppKeys.blogPostCreateScreenVideoLinkTextField,
controller: videoUrlController,
hasError: state.videoUrlError != null,
keyboardType: TextInputType.url,
onChanged: (_) => blogPostCubit.clearVideoUrlError(),
),
if (state.videoUrlError != null) ...[
const SizedBox(height: 4),
ValidationErrorWidget(error: state.videoUrlError!),
],
if (state.formError != null) ...[
const SizedBox(height: 16),
ValidationErrorWidget(error: state.formError!),
],
const SizedBox(height: 80),
_createBlogPostButton(isSubmitting: state.isLoading),
const SizedBox(height: 80),
_buildEditorToolbar(context),
],
),
SafeArea(
top: false,
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
if (state.formError != null) ...[
ValidationErrorWidget(error: state.formError!),
const SizedBox(height: 8),
],
_createBlogPostButton(isSubmitting: state.isLoading),
],
),
),
),
],
);
},
),
@@ -897,9 +919,8 @@ class _BlogPostCreateScreenState extends State<BlogPostCreateScreen> {
onTap: isSubmitting ? null : _submitBlogPost,
borderRadius: BorderRadius.circular(16),
child: isSubmitting
? const Center(child: Loader())
? const SizedBox(height: 52, child: Center(child: Loader()))
: Ink(
width: MediaQuery.of(context).size.width - 32,
height: 52,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),

View File

@@ -3,6 +3,7 @@ import 'package:eatvio_app/core/app_config.dart';
import 'package:eatvio_app/core/env_config.dart';
import 'package:eatvio_app/core/locator_service.dart';
import 'package:eatvio_app/core/utils/image_constant.dart';
import 'package:eatvio_app/core/utils/url_utils.dart';
import 'package:eatvio_app/features/dev_tools/presentation/dev_tools_screen.dart';
import 'package:eatvio_app/features/notifications/presentation/notifications_cubit/notifications_cubit.dart';
import 'package:eatvio_app/features/notifications/presentation/notifications_cubit/notifications_state.dart';
@@ -26,11 +27,13 @@ class BottomNavigationBarWidget extends StatefulWidget {
});
final Widget child;
/// Текущий путь из GoRouter (matchedLocation), чтобы подсветка таба совпадала с экраном при навигации через go() (диплинки, пуши).
final String currentLocation;
@override
State<BottomNavigationBarWidget> createState() => _BottomNavigationBarWidgetState();
State<BottomNavigationBarWidget> createState() =>
_BottomNavigationBarWidgetState();
}
class _BottomNavigationBarWidgetState extends State<BottomNavigationBarWidget> {
@@ -40,9 +43,24 @@ class _BottomNavigationBarWidgetState extends State<BottomNavigationBarWidget> {
List<String> _getPages(bool isConsultant) {
if (isConsultant) {
return ['/my-profile-screen', '/feed-screen', '/diary-screen', '/consultations-screen', '/chat-select-screen', '/notifications-screen', '/menu-screen'];
return [
'/my-profile-screen',
'/feed-screen',
'/diary-screen',
'/consultations-screen',
'/chat-select-screen',
'/notifications-screen',
'/menu-screen',
];
} else {
return ['/my-profile-screen', '/feed-screen', '/diary-screen', '/chat-select-screen', '/notifications-screen', '/menu-screen'];
return [
'/my-profile-screen',
'/feed-screen',
'/diary-screen',
'/chat-select-screen',
'/notifications-screen',
'/menu-screen',
];
}
}
@@ -98,7 +116,9 @@ class _BottomNavigationBarWidgetState extends State<BottomNavigationBarWidget> {
final logo = SvgPicture.asset(
ImageConstant.imgLogo,
height: 32,
colorFilter: isProduction ? null : const ColorFilter.mode(_devAccentColor, BlendMode.srcIn),
colorFilter: isProduction
? null
: const ColorFilter.mode(_devAccentColor, BlendMode.srcIn),
);
if (isProduction) return logo;
return Stack(
@@ -109,7 +129,10 @@ class _BottomNavigationBarWidgetState extends State<BottomNavigationBarWidget> {
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
border: Border.all(color: _devAccentColor.withValues(alpha: 0.6), width: 1.5),
border: Border.all(
color: _devAccentColor.withValues(alpha: 0.6),
width: 1.5,
),
color: _devAccentColor.withValues(alpha: 0.08),
),
child: logo,
@@ -122,11 +145,23 @@ class _BottomNavigationBarWidgetState extends State<BottomNavigationBarWidget> {
decoration: BoxDecoration(
color: _devAccentColor,
borderRadius: BorderRadius.circular(4),
boxShadow: [BoxShadow(color: _devAccentColor.withValues(alpha: 0.4), blurRadius: 4, offset: const Offset(0, 1))],
boxShadow: [
BoxShadow(
color: _devAccentColor.withValues(alpha: 0.4),
blurRadius: 4,
offset: const Offset(0, 1),
),
],
),
child: const Text(
'DEV',
style: TextStyle(fontSize: 8, fontWeight: FontWeight.w700, color: Colors.white, letterSpacing: 0.3, height: 1.0),
style: TextStyle(
fontSize: 8,
fontWeight: FontWeight.w700,
color: Colors.white,
letterSpacing: 0.3,
height: 1.0,
),
),
),
),
@@ -155,10 +190,14 @@ class _BottomNavigationBarWidgetState extends State<BottomNavigationBarWidget> {
// Подсветка таба по текущему маршруту (совпадает при навигации через go(), в т.ч. диплинки/пуши).
final pathWithoutQuery = widget.currentLocation.split('?').first;
final indexFromRoute = pages!.indexOf(pathWithoutQuery);
final effectiveIndex = indexFromRoute >= 0 ? indexFromRoute : currentBottomNavBarIndex;
if (indexFromRoute >= 0 && indexFromRoute != currentBottomNavBarIndex) {
final effectiveIndex = indexFromRoute >= 0
? indexFromRoute
: currentBottomNavBarIndex;
if (indexFromRoute >= 0 &&
indexFromRoute != currentBottomNavBarIndex) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) setState(() => currentBottomNavBarIndex = indexFromRoute);
if (mounted)
setState(() => currentBottomNavBarIndex = indexFromRoute);
});
}
@@ -176,9 +215,14 @@ class _BottomNavigationBarWidgetState extends State<BottomNavigationBarWidget> {
label: '',
icon: CircleAvatar(
foregroundImage: NetworkImage(
profileModel.profilePhotoUrl.contains('https') ? profileModel.profilePhotoUrl : sl<AppConfig>().baseUrl + profileModel.profilePhotoUrl,
joinBaseUrl(
sl<AppConfig>().baseUrl,
profileModel.profilePhotoUrl,
),
),
backgroundImage: const AssetImage(
'assets/images/no_avatar.png',
),
backgroundImage: const AssetImage('assets/images/no_avatar.png'),
radius: 16,
),
),
@@ -186,22 +230,54 @@ class _BottomNavigationBarWidgetState extends State<BottomNavigationBarWidget> {
label: 'Лента',
icon: Padding(
padding: const EdgeInsets.only(bottom: 2),
child: SvgPicture.asset(colorFilter: const ColorFilter.mode(Colors.grey, BlendMode.srcIn), width: 20, height: 20, ImageConstant.icnFeed),
child: SvgPicture.asset(
colorFilter: const ColorFilter.mode(
Colors.grey,
BlendMode.srcIn,
),
width: 20,
height: 20,
ImageConstant.icnFeed,
),
),
activeIcon: Padding(
padding: const EdgeInsets.only(bottom: 2),
child: SvgPicture.asset(ImageConstant.icnFeed, width: 20, height: 20, colorFilter: ColorFilter.mode(appTheme.blueA400, BlendMode.srcIn)),
child: SvgPicture.asset(
ImageConstant.icnFeed,
width: 20,
height: 20,
colorFilter: ColorFilter.mode(
appTheme.blueA400,
BlendMode.srcIn,
),
),
),
),
BottomNavigationBarItem(
label: s.diary,
icon: Padding(
padding: const EdgeInsets.only(bottom: 2),
child: SvgPicture.asset(colorFilter: const ColorFilter.mode(Colors.grey, BlendMode.srcIn), ImageConstant.icnDiary, width: 20, height: 20),
child: SvgPicture.asset(
colorFilter: const ColorFilter.mode(
Colors.grey,
BlendMode.srcIn,
),
ImageConstant.icnDiary,
width: 20,
height: 20,
),
),
activeIcon: Padding(
padding: const EdgeInsets.only(bottom: 2),
child: SvgPicture.asset(ImageConstant.icnDiary, width: 20, height: 20, colorFilter: ColorFilter.mode(appTheme.blueA400, BlendMode.srcIn)),
child: SvgPicture.asset(
ImageConstant.icnDiary,
width: 20,
height: 20,
colorFilter: ColorFilter.mode(
appTheme.blueA400,
BlendMode.srcIn,
),
),
),
),
if (profileModel.roles.contains('consultant'))
@@ -210,7 +286,10 @@ class _BottomNavigationBarWidgetState extends State<BottomNavigationBarWidget> {
icon: Padding(
padding: const EdgeInsets.only(bottom: 2),
child: SvgPicture.asset(
colorFilter: const ColorFilter.mode(Colors.grey, BlendMode.srcIn),
colorFilter: const ColorFilter.mode(
Colors.grey,
BlendMode.srcIn,
),
'assets/icons/consultations.svg',
width: 20,
height: 20,
@@ -222,7 +301,10 @@ class _BottomNavigationBarWidgetState extends State<BottomNavigationBarWidget> {
'assets/icons/consultations.svg',
width: 20,
height: 20,
colorFilter: ColorFilter.mode(appTheme.blueA400, BlendMode.srcIn),
colorFilter: ColorFilter.mode(
appTheme.blueA400,
BlendMode.srcIn,
),
),
),
),
@@ -230,14 +312,26 @@ class _BottomNavigationBarWidgetState extends State<BottomNavigationBarWidget> {
label: s.chat,
icon: BlocBuilder<ChatsCubit, ChatsState>(
builder: (BuildContext context, ChatsState state) {
final hasUnreadMessages = state.chatsWithUnreadMessagesCount > 0;
final hasUnreadMessages =
state.chatsWithUnreadMessagesCount > 0;
return badges.Badge(
position: badges.BadgePosition.topEnd(),
badgeAnimation: const badges.BadgeAnimation.fade(),
badgeStyle: badges.BadgeStyle(badgeColor: hasUnreadMessages ? Colors.red : Colors.transparent),
badgeStyle: badges.BadgeStyle(
badgeColor: hasUnreadMessages
? Colors.red
: Colors.transparent,
),
badgeContent: Text(
hasUnreadMessages ? state.chatsWithUnreadMessagesCount.toString() : '',
style: TextStyle(color: hasUnreadMessages ? Colors.white : Colors.transparent, fontSize: 8),
hasUnreadMessages
? state.chatsWithUnreadMessagesCount.toString()
: '',
style: TextStyle(
color: hasUnreadMessages
? Colors.white
: Colors.transparent,
fontSize: 8,
),
),
child: Padding(
padding: const EdgeInsets.only(bottom: 2),
@@ -245,9 +339,16 @@ class _BottomNavigationBarWidgetState extends State<BottomNavigationBarWidget> {
ImageConstant.icnChat,
width: 20,
height: 20,
colorFilter: pages![effectiveIndex] == '/chat-select-screen'
? ColorFilter.mode(appTheme.blueA400, BlendMode.srcIn)
: const ColorFilter.mode(Colors.grey, BlendMode.srcIn),
colorFilter:
pages![effectiveIndex] == '/chat-select-screen'
? ColorFilter.mode(
appTheme.blueA400,
BlendMode.srcIn,
)
: const ColorFilter.mode(
Colors.grey,
BlendMode.srcIn,
),
),
),
);
@@ -261,15 +362,28 @@ class _BottomNavigationBarWidgetState extends State<BottomNavigationBarWidget> {
return badges.Badge(
position: badges.BadgePosition.topEnd(),
badgeAnimation: const badges.BadgeAnimation.fade(),
showBadge: state is NotificationsStateLoaded && state.hasUnreadNotifications,
badgeContent: const Text('', style: TextStyle(color: Colors.white, fontSize: 8)),
showBadge:
state is NotificationsStateLoaded &&
state.hasUnreadNotifications,
badgeContent: const Text(
'',
style: TextStyle(color: Colors.white, fontSize: 8),
),
child: Padding(
padding: const EdgeInsets.only(bottom: 2),
child: SvgPicture.asset(
ImageConstant.icnBell,
colorFilter: pages![effectiveIndex] == '/notifications-screen'
? ColorFilter.mode(appTheme.blueA400, BlendMode.srcIn)
: const ColorFilter.mode(Colors.grey, BlendMode.srcIn),
colorFilter:
pages![effectiveIndex] ==
'/notifications-screen'
? ColorFilter.mode(
appTheme.blueA400,
BlendMode.srcIn,
)
: const ColorFilter.mode(
Colors.grey,
BlendMode.srcIn,
),
width: 20,
height: 20,
),

View File

@@ -3,6 +3,7 @@ import 'package:eatvio_app/core/locator_service.dart';
import 'package:eatvio_app/features/diets/presentation/widgets/info_bouble.dart';
import 'package:eatvio_app/features/feed/models/diets_pagination_model.dart';
import 'package:eatvio_app/core/theme/custom_text_style.dart';
import 'package:eatvio_app/core/utils/url_utils.dart';
import 'package:eatvio_app/core/theme/theme_helper.dart';
import 'package:eatvio_app/features/profile/models/profile_model.dart';
import 'package:eatvio_app/features/user_details/presentation/user_details_screen.dart';
@@ -11,7 +12,14 @@ import 'package:flutter_svg/svg.dart';
import 'package:go_router/go_router.dart';
class AuthorCard extends StatelessWidget {
const AuthorCard({super.key, required this.author, required this.title, this.backgroundColor, this.titleStyle, this.customerProfile});
const AuthorCard({
super.key,
required this.author,
required this.title,
this.backgroundColor,
this.titleStyle,
this.customerProfile,
});
final Author author;
final String title;
final Color? backgroundColor;
@@ -21,15 +29,28 @@ class AuthorCard extends StatelessWidget {
Widget build(BuildContext context) {
return GestureDetector(
onTap: () {
GoRouter.of(context).pushNamed('profileScreen', extra: customerProfile != null ? customerProfile!.slug : author.authorSlug);
GoRouter.of(context).pushNamed(
'profileScreen',
extra: customerProfile != null
? customerProfile!.slug
: author.authorSlug,
);
},
child: Container(
width: MediaQuery.sizeOf(context).width,
decoration: BoxDecoration(borderRadius: BorderRadius.circular(12), color: backgroundColor ?? appTheme.cardBackground),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
color: backgroundColor ?? appTheme.cardBackground,
),
child: Stack(
children: [
Padding(
padding: const EdgeInsets.only(left: 16, top: 16, bottom: 24, right: 16),
padding: const EdgeInsets.only(
left: 16,
top: 16,
bottom: 24,
right: 16,
),
child: Column(
children: [
ListTile(
@@ -41,28 +62,43 @@ class AuthorCard extends StatelessWidget {
shape: BoxShape.circle,
image: DecorationImage(
image: NetworkImage(
customerProfile != null
? customerProfile!.profilePhotoUrl.contains('https')
? customerProfile!.profilePhotoUrl
: sl<AppConfig>().baseUrl + customerProfile!.profilePhotoUrl
: author.authorProfileIcon.contains('https')
? author.authorProfileIcon
: sl<AppConfig>().baseUrl + author.authorProfileIcon,
joinBaseUrl(
sl<AppConfig>().baseUrl,
customerProfile != null
? customerProfile!.profilePhotoUrl
: author.authorProfileIcon,
),
),
fit: BoxFit.cover,
),
),
),
subtitle: Text(
customerProfile != null ? customerProfile!.name : author.authorName,
style: TextStyles.textLSemibold.copyWith(color: appTheme.black),
customerProfile != null
? customerProfile!.name
: author.authorName,
style: TextStyles.textLSemibold.copyWith(
color: appTheme.black,
),
),
title: Text(
title,
style:
titleStyle ??
TextStyles.textSSemibold.copyWith(
color: appTheme.gray,
),
),
title: Text(title, style: titleStyle ?? TextStyles.textSSemibold.copyWith(color: appTheme.gray)),
),
Row(
children: [
if (customerProfile == null) ...[
Text('Консультант', style: TextStyles.textMMedium.copyWith(color: appTheme.black)),
Text(
'Консультант',
style: TextStyles.textMMedium.copyWith(
color: appTheme.black,
),
),
const SizedBox(width: 10),
if (author.authorPlaceInRating != null)
@@ -70,20 +106,35 @@ class AuthorCard extends StatelessWidget {
radius: 12,
backgroundColor: appTheme.cardBackground,
children: [
Text('${author.authorPlaceInRating}-й в рейтинге', style: TextStyles.textMMediumAuto.copyWith(color: appTheme.blueA400)),
Text(
'${author.authorPlaceInRating}-й в рейтинге',
style: TextStyles.textMMediumAuto.copyWith(
color: appTheme.blueA400,
),
),
],
),
] else
Expanded(
child: GestureDetector(
onTap: () {
GoRouter.of(context).pushNamed(UserDetailsScreen.name, extra: customerProfile!.slug);
GoRouter.of(context).pushNamed(
UserDetailsScreen.name,
extra: customerProfile!.slug,
);
},
child: InfoBouble(
radius: 12,
backgroundColor: appTheme.cardBackground,
mainAxisAlignment: MainAxisAlignment.center,
children: [Text('Анкета пользователя', style: TextStyles.textMMediumAuto.copyWith(color: appTheme.blueA400))],
children: [
Text(
'Анкета пользователя',
style: TextStyles.textMMediumAuto.copyWith(
color: appTheme.blueA400,
),
),
],
),
),
),
@@ -98,8 +149,18 @@ class AuthorCard extends StatelessWidget {
child: Container(
width: 28,
height: 28,
decoration: BoxDecoration(color: appTheme.whiteA700, borderRadius: BorderRadius.circular(8)),
child: Center(child: SvgPicture.asset('assets/icons/arrow.svg', width: 12, height: 12, fit: BoxFit.none)),
decoration: BoxDecoration(
color: appTheme.whiteA700,
borderRadius: BorderRadius.circular(8),
),
child: Center(
child: SvgPicture.asset(
'assets/icons/arrow.svg',
width: 12,
height: 12,
fit: BoxFit.none,
),
),
),
),
],

View File

@@ -3,6 +3,7 @@ import 'package:eatvio_app/core/comments/presentation/comments_actions_bottom_sh
import 'package:eatvio_app/core/enums/complained_content.dart';
import 'package:eatvio_app/core/locator_service.dart';
import 'package:eatvio_app/core/utils/date_time_utils.dart';
import 'package:eatvio_app/core/utils/url_utils.dart';
import 'package:eatvio_app/core/utils/toasts.dart';
import 'package:eatvio_app/features/bottom_navigation_bar/presentation/bloc/profile_avatar_cubit/profile_avatar_cubit.dart';
import 'package:eatvio_app/features/bottom_navigation_bar/presentation/bloc/profile_avatar_cubit/profile_avatar_state.dart';
@@ -54,13 +55,15 @@ class _CommentsWidgetState extends State<CommentsWidget> {
@override
void didUpdateWidget(CommentsWidget oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.highlightedCommentId != null && widget.highlightedCommentId != oldWidget.highlightedCommentId) {
if (widget.highlightedCommentId != null &&
widget.highlightedCommentId != oldWidget.highlightedCommentId) {
WidgetsBinding.instance.addPostFrameCallback((_) {
_scrollToHighlightedComment();
});
} else if (widget.comment != oldWidget.comment && widget.highlightedCommentId != null) {
// Comments might have just loaded
WidgetsBinding.instance.addPostFrameCallback((_) {
} else if (widget.comment != oldWidget.comment &&
widget.highlightedCommentId != null) {
// Comments might have just loaded
WidgetsBinding.instance.addPostFrameCallback((_) {
_scrollToHighlightedComment();
});
}
@@ -84,9 +87,22 @@ class _CommentsWidgetState extends State<CommentsWidget> {
children: [
for (int i = 0; i < widget.comment.data.length; i++) ...[
_comment(s, widget.comment.data[i]),
if (widget.comment.data[i].childrenComments.isNotEmpty) const SizedBox(height: 8),
for (int j = 0; j < widget.comment.data[i].childrenComments.length; j++)
Padding(padding: const EdgeInsets.only(left: 40), child: _comment(s, widget.comment.data[i].childrenComments[j], 16, true)),
if (widget.comment.data[i].childrenComments.isNotEmpty)
const SizedBox(height: 8),
for (
int j = 0;
j < widget.comment.data[i].childrenComments.length;
j++
)
Padding(
padding: const EdgeInsets.only(left: 40),
child: _comment(
s,
widget.comment.data[i].childrenComments[j],
16,
true,
),
),
const SizedBox(height: 8),
],
const SizedBox(height: 48),
@@ -94,9 +110,18 @@ class _CommentsWidgetState extends State<CommentsWidget> {
);
}
Widget _comment(S s, Comment comment, [double radius = 18, bool isChild = false]) {
Widget _comment(
S s,
Comment comment, [
double radius = 18,
bool isChild = false,
]) {
final key = commentKeys.putIfAbsent(comment.id, () => GlobalKey());
final isMe = (sl<ProfileAvatarCubit>().state as ProfileAvatarStateLoaded).profile.slug == comment.authorSlug;
final isMe =
(sl<ProfileAvatarCubit>().state as ProfileAvatarStateLoaded)
.profile
.slug ==
comment.authorSlug;
return GestureDetector(
key: key,
onTap: () {
@@ -113,7 +138,10 @@ class _CommentsWidgetState extends State<CommentsWidget> {
showInfoToast(s.text_was_copied);
},
edit: (String text) {
context.read<CommentsCubit>().updateComment(id: comment.id, body: text);
context.read<CommentsCubit>().updateComment(
id: comment.id,
body: text,
);
},
delete: () {
context.read<CommentsCubit>().deleteComment(id: comment.id);
@@ -122,7 +150,10 @@ class _CommentsWidgetState extends State<CommentsWidget> {
showModalBottomSheet(
context: context,
isScrollControlled: true,
builder: (context) => ComplainingBottomSheet(complainedContentType: ComplainedContent.comment, id: comment.id),
builder: (context) => ComplainingBottomSheet(
complainedContentType: ComplainedContent.comment,
id: comment.id,
),
);
},
isMyComment: isMe,
@@ -131,7 +162,11 @@ class _CommentsWidgetState extends State<CommentsWidget> {
child: Container(
width: MediaQuery.of(context).size.width,
decoration: BoxDecoration(
color: (widget.replyId == comment.id || widget.highlightedCommentId == comment.id) ? appTheme.blueA400.withValues(alpha: 0.1) : Colors.white,
color:
(widget.replyId == comment.id ||
widget.highlightedCommentId == comment.id)
? appTheme.blueA400.withValues(alpha: 0.1)
: Colors.white,
borderRadius: BorderRadius.circular(16),
),
child: Padding(
@@ -141,14 +176,20 @@ class _CommentsWidgetState extends State<CommentsWidget> {
children: [
GestureDetector(
onTap: () {
GoRouter.of(context).pushNamed(
'profileScreen',
extra: comment.authorSlug,
);
GoRouter.of(
context,
).pushNamed('profileScreen', extra: comment.authorSlug);
},
child: CircleAvatar(
foregroundImage: NetworkImage(comment.authorProfileIcon.contains('https') ? comment.authorProfileIcon : sl<AppConfig>().baseUrl + comment.authorProfileIcon),
backgroundImage: const AssetImage('assets/images/no_avatar.png'),
foregroundImage: NetworkImage(
joinBaseUrl(
sl<AppConfig>().baseUrl,
comment.authorProfileIcon,
),
),
backgroundImage: const AssetImage(
'assets/images/no_avatar.png',
),
radius: radius,
),
),
@@ -159,14 +200,26 @@ class _CommentsWidgetState extends State<CommentsWidget> {
children: [
Text(
comment.authorName,
style: TextStyle(height: 1, color: appTheme.gray, fontSize: 14, fontWeight: FontWeight.w500),
style: TextStyle(
height: 1,
color: appTheme.gray,
fontSize: 14,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 4),
SizedBox(
width: isChild ? MediaQuery.of(context).size.width * 0.72 : MediaQuery.of(context).size.width * 0.8,
width: isChild
? MediaQuery.of(context).size.width * 0.72
: MediaQuery.of(context).size.width * 0.8,
child: Text(
comment.body,
style: TextStyle(height: 1, color: appTheme.black, fontSize: 16, fontWeight: FontWeight.w500),
style: TextStyle(
height: 1,
color: appTheme.black,
fontSize: 16,
fontWeight: FontWeight.w500,
),
),
),
const SizedBox(height: 4),
@@ -174,17 +227,29 @@ class _CommentsWidgetState extends State<CommentsWidget> {
children: [
Text(
comment.createdAt.format('dd MMMM', 'ru'),
style: TextStyle(height: 1, color: appTheme.gray, fontSize: 14, fontWeight: FontWeight.w500),
style: TextStyle(
height: 1,
color: appTheme.gray,
fontSize: 14,
fontWeight: FontWeight.w500,
),
),
const SizedBox(width: 16),
if (!isChild)
GestureDetector(
onTap: () {
context.read<CommentsCubit>().setReplyId(comment.id);
context.read<CommentsCubit>().setReplyId(
comment.id,
);
},
child: Text(
s.answer,
style: TextStyle(height: 1, color: appTheme.gray, fontSize: 14, fontWeight: FontWeight.w600),
style: TextStyle(
height: 1,
color: appTheme.gray,
fontSize: 14,
fontWeight: FontWeight.w600,
),
),
),
],

View File

@@ -2,6 +2,7 @@ import 'package:cached_network_image/cached_network_image.dart';
import 'package:eatvio_app/core/app_config.dart';
import 'package:eatvio_app/core/locator_service.dart';
import 'package:eatvio_app/core/theme/theme_helper.dart';
import 'package:eatvio_app/core/utils/url_utils.dart';
import 'package:eatvio_app/core/widgets/network_svg_picture.dart';
import 'package:eatvio_app/features/feed/models/feed_consultant_model.dart';
import 'package:eatvio_app/features/profile/models/badge_model.dart';
@@ -18,15 +19,15 @@ class ConsultantWidget extends StatelessWidget {
Widget build(BuildContext context) {
return InkWell(
onTap: () {
GoRouter.of(context).pushNamed(
'profileScreen',
extra: consultant.slug,
);
GoRouter.of(context).pushNamed('profileScreen', extra: consultant.slug);
},
borderRadius: BorderRadius.circular(20),
child: Ink(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(borderRadius: BorderRadius.circular(20), color: const Color(0xFFF6F6F6)),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(20),
color: const Color(0xFFF6F6F6),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@@ -37,33 +38,54 @@ class ConsultantWidget extends StatelessWidget {
Row(
children: [
Ink(
padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 8),
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(8)),
padding: const EdgeInsets.symmetric(
vertical: 4,
horizontal: 8,
),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(8),
),
child: Text(
'${consultant.ratingPlace}',
style: TextStyle(fontSize: 20, color: appTheme.blueA400, fontWeight: FontWeight.w600),
style: TextStyle(
fontSize: 20,
color: appTheme.blueA400,
fontWeight: FontWeight.w600,
),
),
),
const SizedBox(width: 8),
if (consultant.photo.contains('https'))
CircleAvatar(foregroundImage: NetworkImage(consultant.photo), backgroundImage: const AssetImage('assets/images/no_avatar.png'))
else
CircleAvatar(
foregroundImage: NetworkImage(sl<AppConfig>().baseUrl + consultant.photo),
backgroundImage: const AssetImage('assets/images/no_avatar.png'),
CircleAvatar(
foregroundImage: NetworkImage(
joinBaseUrl(sl<AppConfig>().baseUrl, consultant.photo),
),
backgroundImage: const AssetImage(
'assets/images/no_avatar.png',
),
),
const SizedBox(width: 8),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
consultant.name,
style: const TextStyle(height: 1, fontWeight: FontWeight.w500, fontSize: 15, color: Color(0xFF323F4B)),
style: const TextStyle(
height: 1,
fontWeight: FontWeight.w500,
fontSize: 15,
color: Color(0xFF323F4B),
),
),
const SizedBox(height: 4),
Text(
'Рейтинг: ${consultant.totalScores}',
style: TextStyle(color: appTheme.blueA400, height: 1, fontWeight: FontWeight.w500, fontSize: 12),
style: TextStyle(
color: appTheme.blueA400,
height: 1,
fontWeight: FontWeight.w500,
fontSize: 12,
),
),
],
),
@@ -71,7 +93,10 @@ class ConsultantWidget extends StatelessWidget {
),
Ink(
padding: const EdgeInsets.all(6),
decoration: BoxDecoration(borderRadius: BorderRadius.circular(4), color: Colors.white),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(4),
color: Colors.white,
),
child: SvgPicture.asset('assets/icons/go_to_arrow.svg'),
),
],
@@ -93,9 +118,21 @@ class ConsultantWidget extends StatelessWidget {
children: [
for (int i = 0; i < badges.length; i++) ...[
badges[i].image.contains('svg')
? NetworkSvgPicture(url: '${sl<AppConfig>().baseUrl}/storage/${badges[i].image}', width: 28, height: 28)
: CachedNetworkImage(imageUrl: '${sl<AppConfig>().baseUrl}/storage/${badges[i].image}', width: 28, height: 28),
i < badges.length - 1 ? const SizedBox(width: 8) : const SizedBox.shrink(),
? NetworkSvgPicture(
url:
'${sl<AppConfig>().baseUrl}/storage/${badges[i].image}',
width: 28,
height: 28,
)
: CachedNetworkImage(
imageUrl:
'${sl<AppConfig>().baseUrl}/storage/${badges[i].image}',
width: 28,
height: 28,
),
i < badges.length - 1
? const SizedBox(width: 8)
: const SizedBox.shrink(),
],
],
),

View File

@@ -11,6 +11,7 @@ import 'package:eatvio_app/core/widgets/markdown_html_widget.dart';
import 'package:eatvio_app/features/diets/presentation/widgets/info_bouble.dart';
import 'package:eatvio_app/features/feed/models/plan_pagination_model.dart';
import 'package:eatvio_app/core/theme/custom_text_style.dart';
import 'package:eatvio_app/core/utils/url_utils.dart';
import 'package:eatvio_app/core/theme/theme_helper.dart';
import 'package:eatvio_app/features/feed/presentation/bloc/plans_cubit/plans_cubit.dart';
import 'package:eatvio_app/generated/l10n.dart';
@@ -37,7 +38,14 @@ class _PlanWidgetState extends State<PlanWidget> {
Widget build(BuildContext context) {
final s = S.of(context);
return InkWell(
onTap: () => GoRouter.of(context).pushNamed('aboutPlanScreen', extra: {'slug': widget.plan.slug, 'index': widget.index, 'navigateToComments': false}),
onTap: () => GoRouter.of(context).pushNamed(
'aboutPlanScreen',
extra: {
'slug': widget.plan.slug,
'index': widget.index,
'navigateToComments': false,
},
),
child: Material(
borderRadius: BorderRadius.circular(12),
color: Colors.white,
@@ -48,9 +56,12 @@ class _PlanWidgetState extends State<PlanWidget> {
Stack(
children: [
ClipRRect(
borderRadius: const BorderRadius.vertical(top: Radius.circular(12)),
borderRadius: const BorderRadius.vertical(
top: Radius.circular(12),
),
child: CachedNetworkImage(
imageUrl: '${sl<AppConfig>().baseUrl}/storage/${widget.plan.image}',
imageUrl:
'${sl<AppConfig>().baseUrl}/storage/${widget.plan.image}',
fit: BoxFit.cover,
width: MediaQuery.of(context).size.width,
height: 426,
@@ -61,14 +72,22 @@ class _PlanWidgetState extends State<PlanWidget> {
child: BookmarkButton(
onTap: () async {
if (widget.plan.isFavorite) {
await context.read<PlansCubit>().removePlanFromFavorite(widget.plan.slug, widget.index);
await context.read<PlansCubit>().removePlanFromFavorite(
widget.plan.slug,
widget.index,
);
} else {
await context.read<PlansCubit>().addPlanToFavorite(widget.plan.slug, widget.index);
await context.read<PlansCubit>().addPlanToFavorite(
widget.plan.slug,
widget.index,
);
}
},
color: appTheme.whiteA700,
isFavorite: widget.plan.isFavorite,
borderRadius: const BorderRadius.vertical(bottom: Radius.circular(12)),
borderRadius: const BorderRadius.vertical(
bottom: Radius.circular(12),
),
width: 40,
height: 44,
size: 19,
@@ -100,7 +119,12 @@ class _PlanWidgetState extends State<PlanWidget> {
bottom: 16,
child: SizedBox(
width: MediaQuery.sizeOf(context).width,
child: InfoRow(plan: widget.plan, itemsBackgroundColor: appTheme.whiteA700, disableDescriptionOfTypes: true, type: ProductType.plan),
child: InfoRow(
plan: widget.plan,
itemsBackgroundColor: appTheme.whiteA700,
disableDescriptionOfTypes: true,
type: ProductType.plan,
),
),
),
],
@@ -111,14 +135,22 @@ class _PlanWidgetState extends State<PlanWidget> {
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Text(widget.plan.name, style: TextStyles.textMSemibold.copyWith(color: appTheme.black, fontSize: 18)),
child: Text(
widget.plan.name,
style: TextStyles.textMSemibold.copyWith(
color: appTheme.black,
fontSize: 18,
),
),
),
if (widget.plan.details.isNotEmpty) ...[
const SizedBox(height: 12),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: MarkdownHtmlWidget(
markdownText: widget.plan.details.length > 200 ? '${widget.plan.details.substring(0, 200)}...' : widget.plan.details,
markdownText: widget.plan.details.length > 200
? '${widget.plan.details.substring(0, 200)}...'
: widget.plan.details,
textColor: appTheme.black,
),
),
@@ -127,7 +159,12 @@ class _PlanWidgetState extends State<PlanWidget> {
padding: EdgeInsets.only(left: 16, top: 8),
child: Text(
'Показать ещё',
style: TextStyle(height: 1, color: Color(0xFF8B8B8B), fontSize: 14, fontWeight: FontWeight.w600),
style: TextStyle(
height: 1,
color: Color(0xFF8B8B8B),
fontSize: 14,
fontWeight: FontWeight.w600,
),
),
),
],
@@ -136,7 +173,10 @@ class _PlanWidgetState extends State<PlanWidget> {
const SizedBox(height: 16),
GestureDetector(
onTap: () {
GoRouter.of(context).pushNamed('profileScreen', extra: widget.plan.author.authorSlug);
GoRouter.of(context).pushNamed(
'profileScreen',
extra: widget.plan.author.authorSlug,
);
},
child: Padding(
padding: const EdgeInsets.only(left: 16.0),
@@ -147,7 +187,15 @@ class _PlanWidgetState extends State<PlanWidget> {
height: 46,
decoration: BoxDecoration(
shape: BoxShape.circle,
image: DecorationImage(image: NetworkImage(sl<AppConfig>().baseUrl + widget.plan.author.authorProfileIcon), fit: BoxFit.cover),
image: DecorationImage(
image: NetworkImage(
joinBaseUrl(
sl<AppConfig>().baseUrl,
widget.plan.author.authorProfileIcon,
),
),
fit: BoxFit.cover,
),
),
),
subtitle: Row(
@@ -155,22 +203,37 @@ class _PlanWidgetState extends State<PlanWidget> {
InfoBouble(
radius: 12,
backgroundColor: appTheme.gray50,
padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 10),
padding: const EdgeInsets.symmetric(
vertical: 4,
horizontal: 10,
),
children: [
Text('${widget.plan.author.authorPlaceInRating}-й в рейтинге', style: TextStyles.textXSSemibold.copyWith(color: appTheme.blueA400)),
Text(
'${widget.plan.author.authorPlaceInRating}-й в рейтинге',
style: TextStyles.textXSSemibold.copyWith(
color: appTheme.blueA400,
),
),
],
),
const Spacer(),
Text(
'${widget.plan.packetsCount == 1 ? widget.plan.packet1Options?.price : '${s.from} ${widget.plan.minPriceOfOptions.toStringAsFixed(0)}'}₽',
style: TextStyles.headingXLSemibold.copyWith(color: appTheme.greenA700),
style: TextStyles.headingXLSemibold.copyWith(
color: appTheme.greenA700,
),
),
const SizedBox(width: 16),
],
),
title: Padding(
padding: const EdgeInsets.only(bottom: 4.0),
child: Text(widget.plan.author.authorName, style: TextStyles.textSSemibold.copyWith(color: appTheme.black)),
child: Text(
widget.plan.author.authorName,
style: TextStyles.textSSemibold.copyWith(
color: appTheme.black,
),
),
),
),
),
@@ -181,12 +244,21 @@ class _PlanWidgetState extends State<PlanWidget> {
child: Row(
children: [
FeedIconButton(
icon: widget.plan.wasLikedByMe ? Icons.favorite_rounded : Icons.favorite_border_rounded,
icon: widget.plan.wasLikedByMe
? Icons.favorite_rounded
: Icons.favorite_border_rounded,
text: widget.plan.totalLikes.toString(),
iconColor: widget.plan.wasLikedByMe ? appTheme.blueA400 : null,
backgroundColor: widget.plan.wasLikedByMe ? appTheme.transparentBlue : null,
iconColor: widget.plan.wasLikedByMe
? appTheme.blueA400
: null,
backgroundColor: widget.plan.wasLikedByMe
? appTheme.transparentBlue
: null,
onTap: () async {
await context.read<PlansCubit>().likePlan(widget.plan.slug, widget.index);
await context.read<PlansCubit>().likePlan(
widget.plan.slug,
widget.index,
);
},
),
const SizedBox(width: 12),
@@ -194,9 +266,14 @@ class _PlanWidgetState extends State<PlanWidget> {
icon: Icons.chat_bubble_outline,
text: widget.plan.totalComments.toString(),
onTap: () {
return GoRouter.of(
context,
).pushNamed('aboutPlanScreen', extra: {'slug': widget.plan.slug, 'index': widget.index, 'navigateToComments': true});
return GoRouter.of(context).pushNamed(
'aboutPlanScreen',
extra: {
'slug': widget.plan.slug,
'index': widget.index,
'navigateToComments': true,
},
);
},
),
const SizedBox(width: 12),
@@ -206,8 +283,11 @@ class _PlanWidgetState extends State<PlanWidget> {
final box = context.findRenderObject() as RenderBox?;
final result = await SharePlus.instance.share(
ShareParams(
uri: Uri.parse('${sl<AppConfig>().baseUrl}/ru/diet/${widget.plan.id}'),
sharePositionOrigin: box!.localToGlobal(Offset.zero) & box.size,
uri: Uri.parse(
'${sl<AppConfig>().baseUrl}/ru/diet/${widget.plan.id}',
),
sharePositionOrigin:
box!.localToGlobal(Offset.zero) & box.size,
),
);
switch (result.status) {

View File

@@ -2,10 +2,12 @@ import 'dart:developer';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:eatvio_app/core/error_reporting/report_from.dart';
import 'package:eatvio_app/features/menu/presentation/bloc/notification_settings_cubit/notification_settings_state.dart';
import 'package:eatvio_app/features/menu/presentation/notification_settings_user_message.dart';
import 'package:eatvio_app/features/menu/repositories/notification_settings_repository.dart';
class NotificationSettingsCubit extends Cubit<NotificationSettingsState> {
final NotificationSettingsRepository _notificationSettingsRepository;
int _alertSeq = 0;
NotificationSettingsCubit({required NotificationSettingsRepository notificationSettingsRepository})
: _notificationSettingsRepository = notificationSettingsRepository,
@@ -30,6 +32,17 @@ class NotificationSettingsCubit extends Cubit<NotificationSettingsState> {
} catch (e, stackTrace) {
await reportFrom(e, stackTrace, this);
log('Error updating notification settings: $e', stackTrace: stackTrace);
final current = state;
if (current is NotificationSettingsStateLoaded) {
emit(
NotificationSettingsStateLoaded(
notificationSettings: current.notificationSettings,
alertMessage: notificationSettingsUpdateAlertMessage(e),
alertId: ++_alertSeq,
),
);
return;
}
emit(NotificationSettingsStateError(errorMessage: e.toString()));
}
}

View File

@@ -14,11 +14,17 @@ class NotificationSettingsStateLoading extends NotificationSettingsState {
class NotificationSettingsStateLoaded extends NotificationSettingsState {
final NotificationSettingsModel notificationSettings;
final String? alertMessage;
final int? alertId;
const NotificationSettingsStateLoaded({required this.notificationSettings});
const NotificationSettingsStateLoaded({
required this.notificationSettings,
this.alertMessage,
this.alertId,
});
@override
List<Object?> get props => [notificationSettings];
List<Object?> get props => [notificationSettings, alertMessage, alertId];
}
class NotificationSettingsStateError extends NotificationSettingsState {

View File

@@ -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;
}

View File

@@ -27,7 +27,37 @@ class NotificationsSettingsContent extends StatelessWidget {
return Scaffold(
appBar: AppBar(title: Text(s.notifications)),
body: BlocBuilder<NotificationSettingsCubit, NotificationSettingsState>(
body: BlocConsumer<NotificationSettingsCubit, NotificationSettingsState>(
listenWhen: (previous, current) {
if (current is! NotificationSettingsStateLoaded || current.alertId == null) {
return false;
}
final previousId = previous is NotificationSettingsStateLoaded ? previous.alertId : null;
return current.alertId != previousId;
},
listener: (context, state) {
if (state is! NotificationSettingsStateLoaded) {
return;
}
final message = state.alertMessage;
if (message == null || message.isEmpty) {
return;
}
showCupertinoDialog<void>(
context: context,
builder: (dialogContext) {
return CupertinoAlertDialog(
content: Text(message),
actions: [
CupertinoDialogAction(
onPressed: () => Navigator.of(dialogContext).pop(),
child: const Text('OK'),
),
],
);
},
);
},
builder: (context, state) {
if (state is NotificationSettingsStateLoading) {
return const Center(child: CircularProgressIndicator());

View File

@@ -1,5 +1,6 @@
import 'package:eatvio_app/core/app_config.dart';
import 'package:eatvio_app/core/locator_service.dart';
import 'package:eatvio_app/core/utils/url_utils.dart';
import 'package:eatvio_app/features/menu/presentation/blocked_user_cubit/blocked_users_cubit.dart';
import 'package:eatvio_app/features/profile/models/profile_model.dart';
import 'package:flutter/material.dart';
@@ -18,16 +19,20 @@ class _BlockedUserWidgetState extends State<BlockedUserWidget> {
@override
Widget build(BuildContext context) {
return InkWell(
onTap: () => GoRouter.of(context).pushNamed(
'profileScreen',
extra: widget.profile.slug,
),
onTap: () => GoRouter.of(
context,
).pushNamed('profileScreen', extra: widget.profile.slug),
child: Ink(
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
child: Row(
children: [
CircleAvatar(
foregroundImage: NetworkImage(sl<AppConfig>().baseUrl + widget.profile.profilePhotoUrl),
foregroundImage: NetworkImage(
joinBaseUrl(
sl<AppConfig>().baseUrl,
widget.profile.profilePhotoUrl,
),
),
backgroundImage: const AssetImage('assets/images/no_avatar.png'),
radius: 28,
),
@@ -50,11 +55,16 @@ class _BlockedUserWidgetState extends State<BlockedUserWidget> {
const Spacer(),
InkWell(
onTap: () {
context.read<BlockedUsersCubit>().unblockUser(widget.profile.slug);
context.read<BlockedUsersCubit>().unblockUser(
widget.profile.slug,
);
},
borderRadius: BorderRadius.circular(4),
child: Ink(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 8,
),
decoration: BoxDecoration(
color: const Color(0xFFF1F2F6),
borderRadius: BorderRadius.circular(4),

View File

@@ -1,4 +1,5 @@
import 'package:eatvio_app/core/app_config.dart';
import 'package:eatvio_app/core/utils/url_utils.dart';
import 'package:eatvio_app/core/app_router.dart';
import 'package:eatvio_app/core/locator_service.dart';
import 'package:eatvio_app/core/theme/custom_text_style.dart';
@@ -14,7 +15,11 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
class ConsultationWidget extends StatelessWidget {
const ConsultationWidget({super.key, required this.consultationModel, required this.dietName});
const ConsultationWidget({
super.key,
required this.consultationModel,
required this.dietName,
});
final ConsultationModel consultationModel;
final String dietName;
@override
@@ -33,7 +38,12 @@ class ConsultationWidget extends StatelessWidget {
children: [
CircleAvatar(
radius: 30,
backgroundImage: NetworkImage(sl<AppConfig>().baseUrl + consultationModel.userProfilePhotoUrl),
backgroundImage: NetworkImage(
joinBaseUrl(
sl<AppConfig>().baseUrl,
consultationModel.userProfilePhotoUrl,
),
),
),
const SizedBox(width: 12),
Column(
@@ -43,18 +53,24 @@ class ConsultationWidget extends StatelessWidget {
children: [
Text(
consultationModel.userName,
style: TextStyles.textLSemibold.copyWith(color: appTheme.black),
style: TextStyles.textLSemibold.copyWith(
color: appTheme.black,
),
),
Text(
consultationModel.planTitle,
style: TextStyles.textMMedium.copyWith(color: const Color(0xFF88949F)),
style: TextStyles.textMMedium.copyWith(
color: const Color(0xFF88949F),
),
),
Text(
'Старт: ${consultationModel.userName}',
style: TextStyles.textMMedium.copyWith(color: const Color(0xFF88949F)),
style: TextStyles.textMMedium.copyWith(
color: const Color(0xFF88949F),
),
),
],
)
),
],
)
else
@@ -62,7 +78,12 @@ class ConsultationWidget extends StatelessWidget {
children: [
CircleAvatar(
radius: 30,
backgroundImage: NetworkImage(sl<AppConfig>().baseUrl + consultationModel.userProfilePhotoUrl),
backgroundImage: NetworkImage(
joinBaseUrl(
sl<AppConfig>().baseUrl,
consultationModel.userProfilePhotoUrl,
),
),
),
const SizedBox(width: 12),
Column(
@@ -72,25 +93,32 @@ class ConsultationWidget extends StatelessWidget {
children: [
Text(
consultationModel.userName,
style: TextStyles.textLSemibold.copyWith(color: appTheme.black),
style: TextStyles.textLSemibold.copyWith(
color: appTheme.black,
),
),
Text(
consultationModel.planTitle,
style: TextStyles.textMMedium.copyWith(color: const Color(0xFF88949F)),
style: TextStyles.textMMedium.copyWith(
color: const Color(0xFF88949F),
),
),
],
)
),
],
),
const SizedBox(
height: 12,
),
const SizedBox(height: 12),
if (consultationModel.personalDietId != null)
CustomOutlinedButton(
text: s.changeDiet,
height: 52,
buttonTextStyle: TextStyles.textMSemibold.copyWith(color: appTheme.gray),
buttonStyle: ButtonStyle(side: const WidgetStatePropertyAll(BorderSide.none), backgroundColor: WidgetStatePropertyAll(appTheme.dividerColor)),
buttonTextStyle: TextStyles.textMSemibold.copyWith(
color: appTheme.gray,
),
buttonStyle: ButtonStyle(
side: const WidgetStatePropertyAll(BorderSide.none),
backgroundColor: WidgetStatePropertyAll(appTheme.dividerColor),
),
onPressed: () async {
showBottomSheetWithBlur(
userRootNavigator: true,
@@ -100,7 +128,11 @@ class ConsultationWidget extends StatelessWidget {
userName: consultationModel.userName,
dietName: dietName,
onTap: () async {
final result = await context.read<PersonalDietSuggestionCubit>().attachDietToConsultation(consultationId: consultationModel.id);
final result = await context
.read<PersonalDietSuggestionCubit>()
.attachDietToConsultation(
consultationId: consultationModel.id,
);
AppRouter.navigatorKey.currentContext?.pop();
if (result) {
if (!context.mounted) return;
@@ -134,8 +166,13 @@ class ConsultationWidget extends StatelessWidget {
CustomOutlinedButton(
text: s.suggestDiet,
height: 52,
buttonTextStyle: TextStyles.textMSemibold.copyWith(color: appTheme.whiteA700),
buttonStyle: ButtonStyle(side: const WidgetStatePropertyAll(BorderSide.none), backgroundColor: WidgetStatePropertyAll(appTheme.blueA400)),
buttonTextStyle: TextStyles.textMSemibold.copyWith(
color: appTheme.whiteA700,
),
buttonStyle: ButtonStyle(
side: const WidgetStatePropertyAll(BorderSide.none),
backgroundColor: WidgetStatePropertyAll(appTheme.blueA400),
),
onPressed: () async {
showBottomSheetWithBlur(
userRootNavigator: true,
@@ -146,7 +183,11 @@ class ConsultationWidget extends StatelessWidget {
dietName: dietName,
onTap: () async {
AppRouter.navigatorKey.currentContext?.pop();
final result = await context.read<PersonalDietSuggestionCubit>().attachDietToConsultation(consultationId: consultationModel.id);
final result = await context
.read<PersonalDietSuggestionCubit>()
.attachDietToConsultation(
consultationId: consultationModel.id,
);
if (result) {
if (!context.mounted) return;
showBottomSheetWithBlur(
@@ -174,7 +215,7 @@ class ConsultationWidget extends StatelessWidget {
),
);
},
)
),
],
),
);
@@ -204,35 +245,33 @@ class _SuggestDietBottomSheet extends State<SuggestDietBottomSheet> {
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(
height: 8,
),
const SizedBox(height: 8),
const BottomSheetHandlerBar(),
const SizedBox(
height: 20,
),
Text('Предложение диеты', style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black)),
const SizedBox(
height: 16,
const SizedBox(height: 20),
Text(
'Предложение диеты',
style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black),
),
const SizedBox(height: 16),
Text(
'Предложить пользователю ${widget.userName} «${widget.dietName}»',
style: TextStyles.textSMedium.copyWith(color: appTheme.black),
textAlign: TextAlign.center,
),
const SizedBox(
height: 8,
),
const SizedBox(height: 8),
CustomOutlinedButton(
text: s.toOffer,
height: 52,
buttonTextStyle: TextStyles.textMSemibold.copyWith(color: appTheme.whiteA700),
buttonStyle: ButtonStyle(side: const WidgetStatePropertyAll(BorderSide.none), backgroundColor: WidgetStatePropertyAll(appTheme.blueA400)),
buttonTextStyle: TextStyles.textMSemibold.copyWith(
color: appTheme.whiteA700,
),
buttonStyle: ButtonStyle(
side: const WidgetStatePropertyAll(BorderSide.none),
backgroundColor: WidgetStatePropertyAll(appTheme.blueA400),
),
onPressed: widget.onTap,
),
const SizedBox(
height: 40,
),
const SizedBox(height: 40),
],
),
);
@@ -249,10 +288,12 @@ class SuggestionSuccessBottomSheet extends StatefulWidget {
final String dietName;
@override
State<SuggestionSuccessBottomSheet> createState() => _SuggestionSuccessBottomSheet();
State<SuggestionSuccessBottomSheet> createState() =>
_SuggestionSuccessBottomSheet();
}
class _SuggestionSuccessBottomSheet extends State<SuggestionSuccessBottomSheet> {
class _SuggestionSuccessBottomSheet
extends State<SuggestionSuccessBottomSheet> {
@override
Widget build(BuildContext context) {
final s = S.of(context);
@@ -261,37 +302,35 @@ class _SuggestionSuccessBottomSheet extends State<SuggestionSuccessBottomSheet>
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(
height: 8,
),
const SizedBox(height: 8),
const BottomSheetHandlerBar(),
const SizedBox(
height: 20,
),
Text('Предложение диеты', style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black)),
const SizedBox(
height: 16,
const SizedBox(height: 20),
Text(
'Предложение диеты',
style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black),
),
const SizedBox(height: 16),
Text(
'«${widget.dietName}» предложена пользователю ${widget.userName}',
style: TextStyles.textSMedium.copyWith(color: appTheme.black),
textAlign: TextAlign.center,
),
const SizedBox(
height: 8,
),
const SizedBox(height: 8),
CustomOutlinedButton(
text: s.ok,
height: 52,
buttonTextStyle: TextStyles.textMSemibold.copyWith(color: appTheme.whiteA700),
buttonStyle: ButtonStyle(side: const WidgetStatePropertyAll(BorderSide.none), backgroundColor: WidgetStatePropertyAll(appTheme.blueA400)),
buttonTextStyle: TextStyles.textMSemibold.copyWith(
color: appTheme.whiteA700,
),
buttonStyle: ButtonStyle(
side: const WidgetStatePropertyAll(BorderSide.none),
backgroundColor: WidgetStatePropertyAll(appTheme.blueA400),
),
onPressed: () async {
AppRouter.navigatorKey.currentContext?.pop();
},
),
const SizedBox(
height: 40,
),
const SizedBox(height: 40),
],
),
);
@@ -308,10 +347,12 @@ class SuggestionFailureBottomSheet extends StatefulWidget {
final String dietName;
@override
State<SuggestionFailureBottomSheet> createState() => _SuggestionFailureBottomSheet();
State<SuggestionFailureBottomSheet> createState() =>
_SuggestionFailureBottomSheet();
}
class _SuggestionFailureBottomSheet extends State<SuggestionFailureBottomSheet> {
class _SuggestionFailureBottomSheet
extends State<SuggestionFailureBottomSheet> {
@override
Widget build(BuildContext context) {
final s = S.of(context);
@@ -320,37 +361,35 @@ class _SuggestionFailureBottomSheet extends State<SuggestionFailureBottomSheet>
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(
height: 8,
),
const SizedBox(height: 8),
const BottomSheetHandlerBar(),
const SizedBox(
height: 20,
),
Text('Предложение диеты', style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black)),
const SizedBox(
height: 16,
const SizedBox(height: 20),
Text(
'Предложение диеты',
style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black),
),
const SizedBox(height: 16),
Text(
'Не удалось предложить диету ${widget.dietName} пользователю ${widget.userName}',
style: TextStyles.textSMedium.copyWith(color: appTheme.black),
textAlign: TextAlign.center,
),
const SizedBox(
height: 8,
),
const SizedBox(height: 8),
CustomOutlinedButton(
text: s.ok,
height: 52,
buttonTextStyle: TextStyles.textMSemibold.copyWith(color: appTheme.whiteA700),
buttonStyle: ButtonStyle(side: const WidgetStatePropertyAll(BorderSide.none), backgroundColor: WidgetStatePropertyAll(appTheme.blueA400)),
buttonTextStyle: TextStyles.textMSemibold.copyWith(
color: appTheme.whiteA700,
),
buttonStyle: ButtonStyle(
side: const WidgetStatePropertyAll(BorderSide.none),
backgroundColor: WidgetStatePropertyAll(appTheme.blueA400),
),
onPressed: () async {
AppRouter.navigatorKey.currentContext?.pop();
},
),
const SizedBox(
height: 40,
),
const SizedBox(height: 40),
],
),
);

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,6 @@
import 'package:eatvio_app/core/app_config.dart';
import 'package:eatvio_app/core/locator_service.dart';
import 'package:eatvio_app/core/utils/url_utils.dart';
import 'package:eatvio_app/features/profile/models/sub_model.dart';
import 'package:eatvio_app/features/profile/presentation/widgets/sub_action_button_widget.dart';
import 'package:flutter/material.dart';
@@ -8,10 +9,7 @@ import 'package:go_router/go_router.dart';
class SubWidget extends StatefulWidget {
final SubModel subModel;
const SubWidget({
super.key,
required this.subModel,
});
const SubWidget({super.key, required this.subModel});
@override
State<SubWidget> createState() => _SubWidgetState();
@@ -21,16 +19,20 @@ class _SubWidgetState extends State<SubWidget> {
@override
Widget build(BuildContext context) {
return InkWell(
onTap: () => GoRouter.of(context).pushNamed(
'profileScreen',
extra: widget.subModel.slug,
),
onTap: () => GoRouter.of(
context,
).pushNamed('profileScreen', extra: widget.subModel.slug),
child: Ink(
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
child: Row(
children: [
CircleAvatar(
foregroundImage: NetworkImage(sl<AppConfig>().baseUrl + widget.subModel.profilePhotoUrl),
foregroundImage: NetworkImage(
joinBaseUrl(
sl<AppConfig>().baseUrl,
widget.subModel.profilePhotoUrl,
),
),
backgroundImage: const AssetImage('assets/images/no_avatar.png'),
radius: 28,
),
@@ -62,7 +64,9 @@ class _SubWidgetState extends State<SubWidget> {
),
const Spacer(),
SubActionButtonWidget(
subActionType: widget.subModel.isEntitySubscribedByMe ? SubActionType.sendMessage : SubActionType.subscribe,
subActionType: widget.subModel.isEntitySubscribedByMe
? SubActionType.sendMessage
: SubActionType.subscribe,
slug: widget.subModel.slug,
name: widget.subModel.name,
porfileIcon: widget.subModel.profilePhotoUrl,

View File

@@ -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:

View File

@@ -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'),

View File

@@ -0,0 +1,112 @@
import 'package:eatvio_app/core/push/hms_client_error.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
group('isHuaweiFamilyBrand', () {
test('matches Huawei and Honor brand or manufacturer', () {
expect(
isHuaweiFamilyBrand(brand: 'HONOR', manufacturer: 'HONOR'),
isTrue,
);
expect(
isHuaweiFamilyBrand(brand: 'Huawei', manufacturer: 'HUAWEI'),
isTrue,
);
expect(
isHuaweiFamilyBrand(brand: 'samsung', manufacturer: 'HONOR'),
isTrue,
);
});
test('does not match other Android brands', () {
expect(
isHuaweiFamilyBrand(brand: 'samsung', manufacturer: 'Samsung'),
isFalse,
);
});
});
group('isHmsClientUnavailable', () {
test('matches 907135003 client api invalid', () {
expect(
isHmsClientUnavailable(
PlatformException(
code: 'HMS_TOKEN_ERROR',
message: '907135003: client api invalid',
),
),
isTrue,
);
});
test('does not match a missing app id', () {
expect(
isHmsClientUnavailable(
PlatformException(
code: 'HMS_TOKEN_ERROR',
message: 'Missing Huawei appId',
),
),
isFalse,
);
expect(isHmsClientUnavailable(StateError('907135003')), isFalse);
});
});
group('pushPlatform', () {
test('uses huawei only when an HMS token exists', () {
expect(
pushPlatform(
isIos: false,
huaweiFamily: true,
hmsAvailable: true,
hasHmsToken: true,
),
'huawei',
);
});
test('falls back to android on Honor without a valid HMS client', () {
expect(
pushPlatform(
isIos: false,
huaweiFamily: true,
hmsAvailable: false,
hasHmsToken: false,
),
'android',
);
expect(
pushPlatform(
isIos: false,
huaweiFamily: true,
hmsAvailable: true,
hasHmsToken: false,
),
'android',
);
});
test('keeps ios and plain android', () {
expect(
pushPlatform(
isIos: true,
huaweiFamily: false,
hmsAvailable: false,
hasHmsToken: false,
),
'ios',
);
expect(
pushPlatform(
isIos: false,
huaweiFamily: false,
hmsAvailable: false,
hasHmsToken: false,
),
'android',
);
});
});
}

View File

@@ -0,0 +1,26 @@
import 'package:eatvio_app/core/utils/url_utils.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
const baseUrl = 'https://dev2.eatvio.com';
test('keeps an absolute default avatar url unchanged', () {
const avatar =
'https://ui-avatars.dev2.eatvio.com/api/?name=%D0%9A+%D0%90&color=7F9CF5&background=EBF4FF';
expect(joinBaseUrl(baseUrl, avatar), avatar);
});
test('keeps an absolute http url unchanged', () {
const photo = 'http://localhost/storage/avatar.jpg';
expect(joinBaseUrl(baseUrl, photo), photo);
});
test('prefixes a relative storage path with the base url', () {
expect(
joinBaseUrl(baseUrl, '/storage/profile-photos/photo.jpg'),
'https://dev2.eatvio.com/storage/profile-photos/photo.jpg',
);
});
}

View File

@@ -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(

View File

@@ -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);
});

View File

@@ -1,3 +1,4 @@
import 'package:dio/dio.dart';
import 'package:eatvio_app/features/menu/presentation/bloc/notification_settings_cubit/notification_settings_cubit.dart';
import 'package:eatvio_app/features/menu/presentation/bloc/notification_settings_cubit/notification_settings_state.dart';
import 'package:eatvio_app/features/menu/repositories/notification_settings_repository.dart';
@@ -103,6 +104,51 @@ void main() {
final errorState = notificationSettingsCubit!.state as NotificationSettingsStateError;
expect(errorState.errorMessage, contains('Ошибка обновления'));
});
test('updateNotificationSettings - validation error keeps the screen and exposes an alert', () async {
notificationSettingsCubit = NotificationSettingsCubit(
notificationSettingsRepository: mockNotificationSettingsRepository,
);
when(mockNotificationSettingsRepository.getNotificationSettings()).thenAnswer((_) async => MenuFixtures.notificationSettingsModel());
await notificationSettingsCubit!.getNotificationSettings();
const message = 'Между приемами пищи должна быть разница минимум час';
when(mockNotificationSettingsRepository.updateNotificationSettings(any)).thenThrow(
DioException(
requestOptions: RequestOptions(path: '/api/profile/update_notification_settings'),
type: DioExceptionType.badResponse,
response: Response(
requestOptions: RequestOptions(path: '/api/profile/update_notification_settings'),
statusCode: 422,
data: {
'message': message,
'errors': {
'diary_reminder_notifications.second_reminder': [message],
},
},
),
),
);
await notificationSettingsCubit!.updateNotificationSettings({
'diary_reminder_notifications': {
'second_reminder': {'enabled': true, 'local_time_hours': 9, 'local_time_mins': 30},
},
});
expect(notificationSettingsCubit!.state, isA<NotificationSettingsStateLoaded>());
final loadedState = notificationSettingsCubit!.state as NotificationSettingsStateLoaded;
expect(loadedState.alertMessage, message);
expect(loadedState.notificationSettings.newUpdatesNotification, true);
when(mockNotificationSettingsRepository.updateNotificationSettings(any)).thenThrow(Exception('сеть'));
await notificationSettingsCubit!.updateNotificationSettings({'new_updates_notification': false});
final retried = notificationSettingsCubit!.state as NotificationSettingsStateLoaded;
expect(retried.alertMessage, 'Не удалось сохранить настройки уведомлений');
expect(retried.alertId, isNot(loadedState.alertId));
expect(retried.notificationSettings, loadedState.notificationSettings);
});
});
}

View File

@@ -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,
);
});
});
}

View File

@@ -1,3 +1,4 @@
import 'package:dio/dio.dart';
import 'package:eatvio_app/core/app_config.dart';
import 'package:eatvio_app/core/app_initializer.dart';
import 'package:eatvio_app/core/locator_service.dart';
@@ -7,6 +8,7 @@ import 'package:eatvio_app/features/menu/presentation/bloc/notification_settings
import 'package:eatvio_app/features/menu/presentation/pages/notifications_settings_screen.dart';
import 'package:eatvio_app/features/menu/repositories/notification_settings_repository.dart';
import 'package:eatvio_app/generated/l10n.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
@@ -111,5 +113,55 @@ void main() {
expect(find.textContaining('Ошибка'), findsOneWidget);
expect(find.text('Повторить'), findsOneWidget);
});
testWidgets('shows an alert instead of replacing the screen when reminder times are too close', (tester) async {
tester.view.physicalSize = const Size(800, 1600);
tester.view.devicePixelRatio = 1.0;
addTearDown(() => tester.view.resetPhysicalSize());
const message = 'Между приемами пищи должна быть разница минимум час';
final repository = MockNotificationSettingsRepository();
final cubit = NotificationSettingsCubit(notificationSettingsRepository: repository);
when(repository.getNotificationSettings()).thenAnswer((_) async => mockNotificationSettings);
when(repository.updateNotificationSettings(any)).thenThrow(
DioException(
requestOptions: RequestOptions(path: '/api/profile/update_notification_settings'),
type: DioExceptionType.badResponse,
response: Response(
requestOptions: RequestOptions(path: '/api/profile/update_notification_settings'),
statusCode: 422,
data: {
'message': message,
'errors': {
'diary_reminder_notifications.second_reminder': [message],
},
},
),
),
);
sl.registerLazySingleton<NotificationSettingsRepository>(() => repository);
sl.registerLazySingleton<NotificationSettingsCubit>(() => cubit);
await tester.pumpWidget(wrap(const NotificationsSettingsScreen()));
await tester.pump();
await tester.pump(const Duration(milliseconds: 100));
expect(find.text('Новые обновления'), findsOneWidget);
await tester.tap(find.byType(CupertinoSwitch).first);
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
expect(find.text(message), findsOneWidget);
expect(find.text('Новые обновления'), findsOneWidget);
expect(find.text('Повторить'), findsNothing);
await tester.tap(find.text('OK'));
await tester.pumpAndSettle();
expect(find.text('Новые обновления'), findsOneWidget);
});
});
}