This commit is contained in:
mso7nueh
2026-03-17 12:18:25 +03:00
69 changed files with 2813 additions and 630 deletions

View File

@@ -21,7 +21,6 @@ import 'package:eatvio_app/core/di/cubit_registrations.dart';
import 'package:eatvio_app/features/bottom_navigation_bar/presentation/bloc/profile_avatar_cubit/profile_avatar_cubit.dart';
import 'package:eatvio_app/features/chat/presentation/bloc/chats_cubit/chats_cubit.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/generated/l10n.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/gestures.dart';
@@ -80,9 +79,6 @@ class _AuthScreenState extends State<AuthScreen> {
try {
await sl<ProfileAvatarCubit>().getMyProfile();
await sl<ChatsCubit>().getChatRoomsWithLoading();
await sl<ChatsCubit>().connectToWebSocket();
await sl<NotificationsCubit>().getNotifications();
await sl<NotificationsCubit>().connectToWebSocket();
await sl<FirebaseApi>().registerPushToken();
showSuccessToast('Успешный вход');
} catch (e) {
@@ -213,9 +209,6 @@ class _AuthScreenState extends State<AuthScreen> {
await sl<WsManager>().initialize();
await sl<ProfileAvatarCubit>().getMyProfile();
await sl<ChatsCubit>().getChatRoomsWithLoading();
await sl<ChatsCubit>().connectToWebSocket();
await sl<NotificationsCubit>().getNotifications();
await sl<NotificationsCubit>().connectToWebSocket();
await sl<FirebaseApi>().registerPushToken();
showSuccessToast('Успешный вход');
} catch (e) {

View File

@@ -13,7 +13,6 @@ import 'package:eatvio_app/core/websockets/ws_manager.dart';
import 'package:eatvio_app/features/bottom_navigation_bar/presentation/bloc/profile_avatar_cubit/profile_avatar_cubit.dart';
import 'package:eatvio_app/features/chat/presentation/bloc/chats_cubit/chats_cubit.dart';
import 'package:eatvio_app/features/create_diet/presentation/widgets/error_widget.dart';
import 'package:eatvio_app/features/notifications/presentation/notifications_cubit/notifications_cubit.dart';
import 'package:eatvio_app/core/firebase_api.dart';
import 'package:eatvio_app/core/utils/toasts.dart';
import 'package:eatvio_app/core/utils/validation_utils.dart';
@@ -74,9 +73,6 @@ class _RegisterViaSocialScreenState extends State<RegisterViaSocialScreen> {
try {
await sl<ProfileAvatarCubit>().getMyProfile();
await sl<ChatsCubit>().getChatRoomsWithLoading();
await sl<ChatsCubit>().connectToWebSocket();
await sl<NotificationsCubit>().getNotifications();
await sl<NotificationsCubit>().connectToWebSocket();
await sl<FirebaseApi>().registerPushToken();
showSuccessToast('Успешная регистрация');
} catch (e) {

View File

@@ -10,7 +10,6 @@ import 'package:eatvio_app/core/utils/toasts.dart';
import 'package:eatvio_app/core/websockets/ws_manager.dart';
import 'package:eatvio_app/features/bottom_navigation_bar/presentation/bloc/profile_avatar_cubit/profile_avatar_cubit.dart';
import 'package:eatvio_app/features/chat/presentation/bloc/chats_cubit/chats_cubit.dart';
import 'package:eatvio_app/features/notifications/presentation/notifications_cubit/notifications_cubit.dart';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:qr_code_scanner_plus/qr_code_scanner_plus.dart';
@@ -76,9 +75,6 @@ class _ScanQRCodeScreenState extends State<ScanQRCodeScreen> {
try {
await sl<ProfileAvatarCubit>().getMyProfile();
await sl<ChatsCubit>().getChatRoomsWithLoading();
await sl<ChatsCubit>().connectToWebSocket();
await sl<NotificationsCubit>().getNotifications();
await sl<NotificationsCubit>().connectToWebSocket();
await sl<FirebaseApi>().registerPushToken();
} catch (e) {
rethrow;

View File

@@ -21,8 +21,9 @@ import 'package:share_plus/share_plus.dart';
class BlogPostScreen extends StatefulWidget {
final int id;
final int? highlightedCommentId;
const BlogPostScreen({super.key, required this.id});
const BlogPostScreen({super.key, required this.id, this.highlightedCommentId});
@override
State<BlogPostScreen> createState() => _BlogPostScreenState();
@@ -303,7 +304,13 @@ class _BlogPostScreenState extends State<BlogPostScreen> {
const SizedBox(height: 16),
if (state is CommentsStateLoading) const Loader(),
if (state is CommentsStateLoaded)
CommentsWidget(id: widget.id.toString(), commentSourceType: CommentSourceType.blogPost, comment: state.comment, replyId: replyId),
CommentsWidget(
id: widget.id.toString(),
commentSourceType: CommentSourceType.blogPost,
comment: state.comment,
replyId: replyId,
highlightedCommentId: widget.highlightedCommentId,
),
],
),
),

View File

@@ -0,0 +1,38 @@
import 'package:flutter/material.dart';
class BottomNavController {
BottomNavController();
void Function(String path, Object? extra)? _onSwitchToRoute;
void register(void Function(String path, Object? extra) callback) {
_onSwitchToRoute = callback;
}
void switchToRoute(String path, [Object? extra]) {
_onSwitchToRoute?.call(path, extra);
}
}
class BottomNavScope extends InheritedWidget {
const BottomNavScope({
required this.controller,
required super.child,
super.key,
});
final BottomNavController controller;
static BottomNavController? maybeOf(BuildContext context) {
return context.dependOnInheritedWidgetOfExactType<BottomNavScope>()?.controller;
}
static BottomNavController of(BuildContext context) {
final controller = maybeOf(context);
assert(controller != null, 'No BottomNavScope found in context');
return controller!;
}
@override
bool updateShouldNotify(BottomNavScope oldWidget) => controller != oldWidget.controller;
}

View File

@@ -8,6 +8,7 @@ import 'package:eatvio_app/features/notifications/presentation/notifications_cub
import 'package:eatvio_app/features/notifications/presentation/notifications_cubit/notifications_state.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';
import 'package:eatvio_app/features/bottom_navigation_bar/presentation/widgets/bottom_nav_scope.dart';
import 'package:eatvio_app/features/chat/presentation/bloc/chats_cubit/chats_cubit.dart';
import 'package:eatvio_app/features/chat/presentation/bloc/chats_cubit/chats_state.dart';
import 'package:eatvio_app/generated/l10n.dart';
@@ -18,9 +19,15 @@ import 'package:flutter_svg/flutter_svg.dart';
import 'package:go_router/go_router.dart';
class BottomNavigationBarWidget extends StatefulWidget {
final Widget child;
const BottomNavigationBarWidget({
super.key,
required this.child,
required this.currentLocation,
});
const BottomNavigationBarWidget({super.key, required this.child});
final Widget child;
/// Текущий путь из GoRouter (matchedLocation), чтобы подсветка таба совпадала с экраном при навигации через go() (диплинки, пуши).
final String currentLocation;
@override
State<BottomNavigationBarWidget> createState() => _BottomNavigationBarWidgetState();
@@ -66,6 +73,24 @@ class _BottomNavigationBarWidgetState extends State<BottomNavigationBarWidget> {
}
}
/// Switch to the tab that corresponds to [path], optionally with [extra]
/// (e.g. date for diary). If [path] is not in the current tab list, falls back to push.
void _switchToRoute(String path, [Object? extra]) {
if (pages == null) return;
final index = pages!.indexOf(path);
if (index >= 0 && index != currentBottomNavBarIndex) {
setState(() {
currentBottomNavBarIndex = index;
});
GoRouter.of(context).replace(path, extra: extra);
} else if (index >= 0) {
// Same tab but with new extra (e.g. diary with date)
GoRouter.of(context).replace(path, extra: extra);
} else {
GoRouter.of(context).push(path, extra: extra);
}
}
static const Color _devAccentColor = Color(0xFFF59E0B); // amber-500
Widget _buildLogoIcon() {
@@ -121,6 +146,22 @@ class _BottomNavigationBarWidgetState extends State<BottomNavigationBarWidget> {
// Инициализируем pages на основе profileModel
pages ??= _getPages(isConsultant);
// Expose tab switching to descendants (e.g. notification widget)
final controller = BottomNavScope.maybeOf(context);
if (controller != null) {
controller.register(_switchToRoute);
}
// Подсветка таба по текущему маршруту (совпадает при навигации через go(), в т.ч. диплинки/пуши).
final pathWithoutQuery = widget.currentLocation.split('?').first;
final indexFromRoute = pages!.indexOf(pathWithoutQuery);
final effectiveIndex = indexFromRoute >= 0 ? indexFromRoute : currentBottomNavBarIndex;
if (indexFromRoute >= 0 && indexFromRoute != currentBottomNavBarIndex) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) setState(() => currentBottomNavBarIndex = indexFromRoute);
});
}
return Scaffold(
body: widget.child,
bottomNavigationBar: BottomNavigationBar(
@@ -129,7 +170,7 @@ class _BottomNavigationBarWidgetState extends State<BottomNavigationBarWidget> {
selectedFontSize: 10,
unselectedFontSize: 10,
selectedItemColor: appTheme.blueA400,
currentIndex: currentBottomNavBarIndex,
currentIndex: effectiveIndex,
items: [
BottomNavigationBarItem(
label: '',
@@ -204,7 +245,7 @@ class _BottomNavigationBarWidgetState extends State<BottomNavigationBarWidget> {
ImageConstant.icnChat,
width: 20,
height: 20,
colorFilter: pages![currentBottomNavBarIndex] == '/chat-select-screen'
colorFilter: pages![effectiveIndex] == '/chat-select-screen'
? ColorFilter.mode(appTheme.blueA400, BlendMode.srcIn)
: const ColorFilter.mode(Colors.grey, BlendMode.srcIn),
),
@@ -226,7 +267,7 @@ class _BottomNavigationBarWidgetState extends State<BottomNavigationBarWidget> {
padding: const EdgeInsets.only(bottom: 2),
child: SvgPicture.asset(
ImageConstant.icnBell,
colorFilter: pages![currentBottomNavBarIndex] == '/notifications-screen'
colorFilter: pages![effectiveIndex] == '/notifications-screen'
? ColorFilter.mode(appTheme.blueA400, BlendMode.srcIn)
: const ColorFilter.mode(Colors.grey, BlendMode.srcIn),
width: 20,

View File

@@ -11,7 +11,11 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
class ChatsScreen extends StatefulWidget {
const ChatsScreen({super.key});
const ChatsScreen({super.key, this.initialSearchQuery});
/// When set (e.g. from notification tap), the search field is pre-filled
/// and chat list is filtered by this query.
final String? initialSearchQuery;
@override
State<ChatsScreen> createState() => _ChatsScreenState();
@@ -23,12 +27,17 @@ class _ChatsScreenState extends State<ChatsScreen> {
@override
void initState() {
super.initState();
// Инициализация ChatsCubit при первом открытии экрана
final query = widget.initialSearchQuery?.trim();
if (query != null && query.isNotEmpty) {
searchController.text = query;
}
WidgetsBinding.instance.addPostFrameCallback((_) {
final cubit = context.read<ChatsCubit>();
final state = cubit.state;
if (state.state == BaseState.initial) {
cubit.getChatRoomsWithLoading();
cubit.getChatRoomsWithLoading(query: query?.isNotEmpty == true ? query : null);
} else if (query != null && query.isNotEmpty) {
cubit.getChatRooms(query: query);
}
});
}

View File

@@ -22,6 +22,9 @@ abstract class ConsultantCertPhotoModel with _$ConsultantCertPhotoModel {
static const String visibleGroup = 'user_certs';
static const String hiddenGroup = 'hidden_user_certs';
static const String pendingGroup = 'pending_user_certs';
bool get isHidden => group == hiddenGroup;
bool get isActive => group == visibleGroup || group == hiddenGroup;
}

View File

@@ -1,6 +1,5 @@
import 'dart:io';
import 'package:eatvio_app/core/image_compressing/image_compress_utility.dart';
import 'package:eatvio_app/features/consultant/become_consultant/models/consultant_cert_photo_model.dart';
import 'package:eatvio_app/features/consultant/become_consultant/models/consultant_profile_model.dart';
import 'package:eatvio_app/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_state.dart';
import 'package:eatvio_app/features/consultant/become_consultant/repositories/consultant_repository.dart';
@@ -16,13 +15,14 @@ class RegisterConsultantCubit extends Cubit<RegisterConsultantState> {
consultant: ConsultantProfileModel(socialLinks: SocialLinksModel(), hasMedicalEducation: true),
loading: true,
acceptOffer: false,
isUpdating: false,
),
);
Future<void> getMyRequestStatus(String slug) async {
_update = true;
final myRequestStatus = await consultantRepository.getMyRequestStatus(slug);
final certPhotos = await _fetchVisibleCertPhotos();
final certPhotos = await consultantRepository.getConsultantCertPhotos();
List<CertificatesModel>? certificates = myRequestStatus.educationModel.completedCourses?.map((courseMap) {
final course = courseMap.values.first;
@@ -51,12 +51,13 @@ class RegisterConsultantCubit extends Cubit<RegisterConsultantState> {
loading: false,
requestModel: myRequestStatus,
certPhotos: certPhotos,
isUpdating: _update
),
);
}
Future<void> disableLoading() async {
final certPhotos = await _fetchVisibleCertPhotos();
final certPhotos = await consultantRepository.getConsultantCertPhotos();
emit(state.copyWith(loading: false, certPhotos: certPhotos));
}
@@ -65,33 +66,33 @@ class RegisterConsultantCubit extends Cubit<RegisterConsultantState> {
}
Future<void> registerConsultant() async {
if (state.newAddedPhotos.isNotEmpty) {
await consultantRepository.storeConsultantCertPhotos(files: state.newAddedPhotos);
}
if (state.deletedPhotoIds.isNotEmpty) {
for (final int id in state.deletedPhotoIds) {
await consultantRepository.deletePhoto(id);
}
}
await consultantRepository.registerConsultant(consultant: state.consultant, update: _update);
}
Future<void> uploadCertPhotos(List<File> files) async {
Future<void> addCertPhotos(List<File> files) async {
if (files.isEmpty) {
return;
}
final compressedFiles = await ImageCompressUtility.compressFilesList(files);
final uploaded = await consultantRepository.storeConsultantCertPhotos(files: compressedFiles);
emit(state.copyWith(certPhotos: _onlyVisible(uploaded)));
emit(state.copyWith(newAddedPhotos: [...state.newAddedPhotos, ...compressedFiles]));
}
Future<void> removeCertPhoto(int id) async {
await consultantRepository.hideConsultantCertPhoto(id);
emit(state.copyWith(certPhotos: state.certPhotos.where((photo) => photo.id != id).toList()));
}
Future<List<ConsultantCertPhotoModel>> _fetchVisibleCertPhotos() async {
try {
final photos = await consultantRepository.getConsultantCertPhotos();
return _onlyVisible(photos);
} catch (e) {
return [];
Future<void> removeCertPhoto(int? id, String? path) async {
if (id != null) {
emit(state.copyWith(deletedPhotoIds: [...state.deletedPhotoIds, id]));
return;
}
if (path != null) {
emit(state.copyWith(newAddedPhotos: state.newAddedPhotos.where((file) => file.uri.path != path).toList()));
}
}
List<ConsultantCertPhotoModel> _onlyVisible(List<ConsultantCertPhotoModel> photos) {
return photos.where((photo) => !photo.isHidden).toList();
}
}

View File

@@ -1,3 +1,5 @@
import 'dart:io';
import 'package:eatvio_app/features/consultant/become_consultant/models/consultant_cert_photo_model.dart';
import 'package:eatvio_app/features/consultant/become_consultant/models/consultant_profile_model.dart';
import 'package:eatvio_app/features/consultant/become_consultant/models/consultation_request_model.dart';
@@ -11,7 +13,10 @@ abstract class RegisterConsultantState with _$RegisterConsultantState {
required ConsultantProfileModel consultant,
required bool acceptOffer,
required bool loading,
required bool isUpdating,
ConsultationRequestModel? requestModel,
@Default(<ConsultantCertPhotoModel>[]) List<ConsultantCertPhotoModel> certPhotos,
@Default(<File>[]) List<File> newAddedPhotos,
@Default(<int>[]) List<int> deletedPhotoIds,
}) = _RegisterConsultantState;
}

View File

@@ -33,7 +33,9 @@ class _PhotoOrScansScreenState extends State<PhotoOrScansScreen> {
const SizedBox(height: 16),
BlocBuilder<RegisterConsultantCubit, RegisterConsultantState>(
builder: (context, state) {
final photos = state.certPhotos;
final photos = state.certPhotos.where((photo) => !state.deletedPhotoIds.contains(photo.id)).toList();
final addedPhotos = state.newAddedPhotos;
final totalPhotosCount = photos.length + addedPhotos.length;
return Column(
children: [
if (isUploading) ...[const LinearProgressIndicator(), const SizedBox(height: 16)],
@@ -47,7 +49,7 @@ class _PhotoOrScansScreenState extends State<PhotoOrScansScreen> {
mainAxisSpacing: 8,
),
itemBuilder: (BuildContext context, int index) {
if (index == photos.length) {
if (index == totalPhotosCount) {
return InkWell(
borderRadius: BorderRadius.circular(20),
onTap: () => _pickAndUploadPhotos(),
@@ -57,10 +59,25 @@ class _PhotoOrScansScreenState extends State<PhotoOrScansScreen> {
),
);
} else {
return _CertPhotoCard(photo: photos[index], allPhotos: photos, onRemove: () => _removePhoto(photos[index].id));
if (index < photos.length) {
final photo = photos[index];
return _CertPhotoCard(
photo: photo,
allPhotos: photos,
onRemove: () => _removePhoto(id: photo.id),
);
} else {
final localIndex = index - photos.length;
final localPhoto = addedPhotos[localIndex];
return _LocalCertPhotoCard(
photo: localPhoto,
allPhotos: addedPhotos,
onRemove: () => _removePhoto(path: localPhoto.uri.path),
);
}
}
},
itemCount: photos.length + 1,
itemCount: totalPhotosCount + 1,
),
],
);
@@ -82,7 +99,7 @@ class _PhotoOrScansScreenState extends State<PhotoOrScansScreen> {
});
try {
// ignore: use_build_context_synchronously
await context.read<RegisterConsultantCubit>().uploadCertPhotos(newImages.map((file) => File(file.path)).toList());
await context.read<RegisterConsultantCubit>().addCertPhotos(newImages.map((file) => File(file.path)).toList());
} finally {
if (mounted) {
setState(() {
@@ -92,8 +109,8 @@ class _PhotoOrScansScreenState extends State<PhotoOrScansScreen> {
}
}
Future<void> _removePhoto(int id) async {
await context.read<RegisterConsultantCubit>().removeCertPhoto(id);
Future<void> _removePhoto({int? id, String? path}) async {
await context.read<RegisterConsultantCubit>().removeCertPhoto(id, path);
}
}
@@ -147,3 +164,61 @@ class _CertPhotoCard extends StatelessWidget {
);
}
}
class _LocalCertPhotoCard extends StatelessWidget {
final File photo;
final List<File> allPhotos;
final VoidCallback onRemove;
const _LocalCertPhotoCard({required this.photo, required this.allPhotos, required this.onRemove});
@override
Widget build(BuildContext context) {
return Hero(
tag: photo.path,
child: Material(
borderRadius: BorderRadius.circular(20),
child: InkWell(
onTap: () {
context.pushTransparentRoute(
rootNavigator: true,
PhotoViewScreen(
imageUrls: allPhotos,
isLocal: true,
index: allPhotos.indexOf(photo),
),
);
},
borderRadius: BorderRadius.circular(20),
child: Stack(
clipBehavior: Clip.none,
children: [
Ink(
decoration: BoxDecoration(
borderRadius: const BorderRadius.all(Radius.circular(20)),
image: DecorationImage(
fit: BoxFit.cover,
image: FileImage(photo),
),
),
),
Positioned(
right: -12,
top: -12,
child: InkWell(
onTap: onRemove,
customBorder: const CircleBorder(),
child: Ink(
padding: const EdgeInsets.all(4),
decoration: const BoxDecoration(color: Color(0xFFF1F2F6), shape: BoxShape.circle),
child: const Icon(size: 20, Icons.close, color: Colors.black),
),
),
),
],
),
),
),
);
}
}

View File

@@ -1,6 +1,5 @@
import 'package:eatvio_app/core/utils/image_constant.dart';
import 'package:eatvio_app/core/widgets/loader.dart';
import 'package:eatvio_app/features/consultant/become_consultant/models/consultation_request_model.dart';
import 'package:eatvio_app/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_cubit.dart';
import 'package:eatvio_app/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_state.dart';
import 'package:eatvio_app/features/consultant/become_consultant/presentation/pages/additional_settings_screen.dart';
@@ -19,8 +18,8 @@ import 'package:lottie/lottie.dart';
class StepperBecomeConsultantScreen extends StatefulWidget {
final String? slug;
const StepperBecomeConsultantScreen({super.key, this.slug});
final bool? isCanceled;
const StepperBecomeConsultantScreen({super.key, this.slug, this.isCanceled});
@override
State<StepperBecomeConsultantScreen> createState() => _StepperBecomeConsultantScreenState();
@@ -63,25 +62,17 @@ class _StepperBecomeConsultantScreenState extends State<StepperBecomeConsultantS
highlightElevation: 0,
focusElevation: 0,
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
backgroundColor: const Color(0xFFEAF2FD),
onPressed: () async {
setState(() {
pageController.previousPage(
duration: const Duration(milliseconds: 350),
curve: Curves.easeInOut,
);
pageController.previousPage(duration: const Duration(milliseconds: 350), curve: Curves.easeInOut);
});
},
label: Icon(
Icons.arrow_back_ios_new_rounded,
color: appTheme.blueA400,
),
label: Icon(Icons.arrow_back_ios_new_rounded, color: appTheme.blueA400),
),
),
if (state.requestModel?.educationModel.requestState == ModerationStatus.canceled)
if (widget.isCanceled == true)
InkWell(
onTap: () {
showDialog(
@@ -94,15 +85,10 @@ class _StepperBecomeConsultantScreenState extends State<StepperBecomeConsultantS
children: [
for (int i = 0; i < state.requestModel!.canceledModeration!.additional!.length; i++) ...[
Text('• ${state.requestModel!.canceledModeration!.additional![i]}'),
]
],
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('OK'),
),
],
actions: [TextButton(onPressed: () => Navigator.pop(context), child: const Text('OK'))],
),
);
},
@@ -110,26 +96,18 @@ class _StepperBecomeConsultantScreenState extends State<StepperBecomeConsultantS
child: Ink(
height: 48,
width: MediaQuery.of(context).size.width * 0.6,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(20),
color: const Color(0x33FFCC00),
),
decoration: BoxDecoration(borderRadius: BorderRadius.circular(20), color: const Color(0x33FFCC00)),
child: const Center(
child: Text(
'Комментарий',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
color: Color(0xFF323F4B),
height: 1,
),
style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: Color(0xFF323F4B), height: 1),
),
),
),
),
Container(
height: 48,
width: state.requestModel?.educationModel.requestState == ModerationStatus.canceled
width: widget.isCanceled == true
? MediaQuery.of(context).size.width * 0.2
: MediaQuery.of(context).size.width * 0.8,
padding: const EdgeInsets.only(left: 4, right: 16),
@@ -138,18 +116,19 @@ class _StepperBecomeConsultantScreenState extends State<StepperBecomeConsultantS
highlightElevation: 0,
elevation: 0,
focusElevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
backgroundColor: isLoading
? Colors.grey
: state.requestModel?.educationModel.requestState == ModerationStatus.canceled
? const Color(0xFFEAF2FD)
: appTheme.blueA400,
: widget.isCanceled == true
? const Color(0xFFEAF2FD)
: appTheme.blueA400,
onPressed: isLoading
? null
: () async {
if (step == 6) {
if (widget.isCanceled == true) {
return;
}
if (registrationConsultantCubit.state.acceptOffer) {
setState(() {
isLoading = true;
@@ -170,27 +149,15 @@ class _StepperBecomeConsultantScreenState extends State<StepperBecomeConsultantS
const Text(
'Спасибо!\nВаши данные отправлены на модерацию',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 20,
height: 1,
fontWeight: FontWeight.w600,
),
style: TextStyle(fontSize: 20, height: 1, fontWeight: FontWeight.w600),
),
const SizedBox(height: 16),
Lottie.asset(
'assets/lottie_animations/success.json',
width: 160,
height: 160,
),
Lottie.asset('assets/lottie_animations/success.json', width: 160, height: 160),
const SizedBox(height: 16),
const Text(
'В течении трех рабочих дней Вам придет уведомление о смене статуса',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 16,
height: 1,
fontWeight: FontWeight.w500,
),
style: TextStyle(fontSize: 16, height: 1, fontWeight: FontWeight.w500),
),
const SizedBox(height: 16),
InkWell(
@@ -201,18 +168,11 @@ class _StepperBecomeConsultantScreenState extends State<StepperBecomeConsultantS
child: Ink(
width: MediaQuery.of(context).size.width - 32,
height: 52,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(20),
color: appTheme.blueA400,
),
decoration: BoxDecoration(borderRadius: BorderRadius.circular(20), color: appTheme.blueA400),
child: const Center(
child: Text(
'Закрыть',
style: TextStyle(
color: Colors.white,
fontSize: 18,
fontWeight: FontWeight.w600,
),
style: TextStyle(color: Colors.white, fontSize: 18, fontWeight: FontWeight.w600),
),
),
),
@@ -263,24 +223,15 @@ class _StepperBecomeConsultantScreenState extends State<StepperBecomeConsultantS
} else {
FocusManager.instance.primaryFocus?.unfocus();
setState(() {
pageController.nextPage(
duration: const Duration(milliseconds: 350),
curve: Curves.easeInOut,
);
pageController.nextPage(duration: const Duration(milliseconds: 350), curve: Curves.easeInOut);
});
}
},
label: isLoading
? const CircularProgressIndicator()
: state.requestModel?.educationModel.requestState == ModerationStatus.canceled
? Icon(
Icons.arrow_forward_ios_outlined,
color: appTheme.blueA400,
)
: Text(
'Продолжить',
style: theme.textTheme.titleSmall!.copyWith(color: Colors.white),
),
: widget.isCanceled == true
? Icon(Icons.arrow_forward_ios_outlined, color: appTheme.blueA400)
: Text('Продолжить', style: theme.textTheme.titleSmall!.copyWith(color: Colors.white)),
),
),
],
@@ -288,23 +239,11 @@ class _StepperBecomeConsultantScreenState extends State<StepperBecomeConsultantS
appBar: AppBar(
automaticallyImplyLeading: false,
leading: Builder(
builder: (context) => IconButton(
icon: const Icon(Icons.close_rounded),
onPressed: () => GoRouter.of(context).pop(),
),
builder: (context) => IconButton(icon: const Icon(Icons.close_rounded), onPressed: () => GoRouter.of(context).pop()),
),
centerTitle: true,
title: SvgPicture.asset(
ImageConstant.imgLogoEatvio,
height: 48,
fit: BoxFit.contain,
),
actions: [
Padding(
padding: const EdgeInsets.only(right: 16),
child: Text('шаг $step из 6'),
),
],
title: SvgPicture.asset(ImageConstant.imgLogoEatvio, height: 48, fit: BoxFit.contain),
actions: [Padding(padding: const EdgeInsets.only(right: 16), child: Text('шаг $step из 6'))],
bottom: PreferredSize(
preferredSize: Size(MediaQuery.of(context).size.width, 32),
child: Padding(
@@ -312,10 +251,7 @@ class _StepperBecomeConsultantScreenState extends State<StepperBecomeConsultantS
child: TweenAnimationBuilder<double>(
duration: const Duration(milliseconds: 350),
curve: Curves.easeInOut,
tween: Tween<double>(
begin: 1 / 6,
end: progressValue,
),
tween: Tween<double>(begin: 1 / 6, end: progressValue),
builder: (context, value, _) => LinearProgressIndicator(
value: value,
borderRadius: BorderRadius.circular(16),
@@ -347,9 +283,7 @@ class _StepperBecomeConsultantScreenState extends State<StepperBecomeConsultantS
),
);
} else {
return const Scaffold(
body: Loader(),
);
return const Scaffold(body: Loader());
}
},
),

View File

@@ -90,4 +90,13 @@ class ConsultantRepository {
rethrow;
}
}
Future<void> deletePhoto(int id) async {
try {
await consultantService.deletePhoto(id: id);
} catch (e, s) {
await Sentry.captureException(e, stackTrace: s);
rethrow;
}
}
}

View File

@@ -40,4 +40,7 @@ abstract class ConsultantService {
@POST('/api/v2/consultant_request/cert_photos/un_hide')
Future<void> unHideConsultantCertPhoto({@Body() required Map<String, dynamic> payload});
@POST('/api/v2/consultant_request/cert_photos/{id}/queue-for-deletion')
Future<void> deletePhoto({@Path() required int id});
}

View File

@@ -172,8 +172,8 @@ class _ConsultationCardWidgetState extends State<ConsultationCardWidget> {
);
return;
}
GoRouter.of(context).push(UserDiaryScreen.path, extra: {'diaryId': day.$2.id, 'currentDiaryDay': index + 1});
},
GoRouter.of(context).push(UserDiaryScreen.path, extra: {'diaryId': day.$2.id,});
},
borderRadius: BorderRadius.circular(100),
child: Ink(
decoration: BoxDecoration(
@@ -255,7 +255,7 @@ class _ConsultationCardWidgetState extends State<ConsultationCardWidget> {
.where((e) => e.$2.state == DiaryStateEnum.finished || e.$2.state == DiaryStateEnum.lastDayFinished)
.lastOrNull;
if (lastActiveDay != null) {
GoRouter.of(context).push(UserDiaryScreen.path, extra: {'diaryId': lastActiveDay.$2.id, 'currentDiaryDay': lastActiveDay.$1});
GoRouter.of(context).push(UserDiaryScreen.path, extra: {'diaryId': lastActiveDay.$2.id,});
return;
}
showErrorDialog(

View File

@@ -48,6 +48,11 @@ class DiaryCubit extends Cubit<DiaryState> {
String comment = '';
void setInitialDate(DateTime? date) {
if (date == null) return;
selectedDay = date;
}
Future<bool> setSelectedDay(DateTime day) async {
final prevDay = selectedDay;
final prevMeal = selectedMeal;
@@ -63,7 +68,7 @@ class DiaryCubit extends Cubit<DiaryState> {
return canSelect;
}
Future<bool> fetchDiaryV2({bool needLoading = false, int? diaryId}) async {
Future<bool> fetchDiaryV2({bool needLoading = false, int? diaryId, DateTime? date}) async {
final currentState = state;
try {
if (needLoading) {
@@ -77,6 +82,7 @@ class DiaryCubit extends Cubit<DiaryState> {
return true;
}
}
final diaryDay = await getDiary();
if (diaryDay == null) {
emit(const DiaryState.error(errorMessage: 'Diary not found'));

View File

@@ -24,31 +24,29 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
class DiaryScreen extends StatelessWidget {
const DiaryScreen({super.key});
const DiaryScreen({super.key, required this.date});
final DateTime? date;
@override
Widget build(BuildContext context) {
return const DiaryScreenContent();
return DiaryScreenContent(date: date);
}
}
class UserDiaryScreen extends StatelessWidget {
static const path = '/user-diary-screen';
static const name = 'userDiaryScreen';
const UserDiaryScreen({super.key, required this.diaryId, required this.currentPlanDay});
const UserDiaryScreen({super.key, required this.diaryId});
final int diaryId;
final int currentPlanDay;
@override
Widget build(BuildContext context) {
return DiaryScreenContent(diaryId: diaryId, currentPlanDay: currentPlanDay);
return DiaryScreenContent(diaryId: diaryId,);
}
}
class DiaryScreenContent extends StatefulWidget {
const DiaryScreenContent({super.key, this.diaryId, this.currentPlanDay});
const DiaryScreenContent({super.key, this.diaryId,this.date});
final int? diaryId;
final int? currentPlanDay;
final DateTime? date;
@override
State<DiaryScreenContent> createState() => _DiaryScreenContentState();
}
@@ -61,7 +59,11 @@ class _DiaryScreenContentState extends State<DiaryScreenContent> {
final s = S.of(context);
return MultiBlocProvider(
providers: [
BlocProvider<DiaryCubit>(create: (BuildContext context) => sl<DiaryCubit>()..fetchDiaryV2(needLoading: true, diaryId: widget.diaryId)),
BlocProvider<DiaryCubit>(
create: (BuildContext context) => sl<DiaryCubit>()
..setInitialDate(widget.date)
..fetchDiaryV2(needLoading: true, diaryId: widget.diaryId),
),
BlocProvider(create: (context) => sl<ConsultantCommentsCubit>()),
],
child: BlocBuilder<DiaryCubit, DiaryState>(
@@ -91,7 +93,7 @@ class _DiaryScreenContentState extends State<DiaryScreenContent> {
physics: const BouncingScrollPhysics(),
child: Column(
children: [
if (widget.diaryId == null) const CalendarWidget(),
if (widget.diaryId == null) CalendarWidget(date: widget.date),
if (state is DiaryStateLoading) ...[const SizedBox(height: 60), const Loader()],
if (state is DiaryStateLoaded)
WakeupAndRetireWidget(

View File

@@ -8,7 +8,8 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:table_calendar/table_calendar.dart';
class CalendarWidget extends StatefulWidget {
const CalendarWidget({super.key});
const CalendarWidget({super.key, this.date});
final DateTime? date;
@override
State<CalendarWidget> createState() => _CalendarWidgetState();
@@ -18,6 +19,23 @@ class _CalendarWidgetState extends State<CalendarWidget> {
CalendarFormat _calendarFormat = CalendarFormat.week;
DateTime _focusedDay = DateTime.now();
DateTime _selectedDay = DateTime.now();
@override
void initState() {
super.initState();
if (widget.date != null) {
_selectedDay = widget.date!;
_focusedDay = widget.date!;
}
}
@override
void didUpdateWidget(CalendarWidget oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.date != null && widget.date != oldWidget.date) {
_selectedDay = widget.date!;
_focusedDay = widget.date!;
}
}
@override
Widget build(BuildContext context) {

View File

@@ -37,8 +37,9 @@ class AboutDietScreen extends StatefulWidget {
final int? id;
final int? index;
final bool navigateToComments;
final int? highlightedCommentId;
const AboutDietScreen({super.key, required this.index, required this.navigateToComments, required this.slug, required this.id});
const AboutDietScreen({super.key, required this.index, required this.navigateToComments, required this.slug, required this.id, this.highlightedCommentId});
@override
State<AboutDietScreen> createState() => _AboutDietScreenState();
@@ -433,6 +434,7 @@ class _AboutDietScreenState extends State<AboutDietScreen> {
commentSourceType: CommentSourceType.diet,
comment: state.comment,
replyId: replyId,
highlightedCommentId: widget.highlightedCommentId,
),
],
),

View File

@@ -23,14 +23,60 @@ class CommentsWidget extends StatefulWidget {
final CommentSourceType commentSourceType;
final CommentModel comment;
final int? replyId;
final int? highlightedCommentId;
const CommentsWidget({super.key, required this.id, required this.commentSourceType, required this.comment, this.replyId});
const CommentsWidget({
super.key,
required this.id,
required this.commentSourceType,
required this.comment,
this.replyId,
this.highlightedCommentId,
});
@override
State<CommentsWidget> createState() => _CommentsWidgetState();
}
class _CommentsWidgetState extends State<CommentsWidget> {
final Map<int, GlobalKey> commentKeys = {};
@override
void initState() {
super.initState();
if (widget.highlightedCommentId != null) {
WidgetsBinding.instance.addPostFrameCallback((_) {
_scrollToHighlightedComment();
});
}
}
@override
void didUpdateWidget(CommentsWidget oldWidget) {
super.didUpdateWidget(oldWidget);
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((_) {
_scrollToHighlightedComment();
});
}
}
void _scrollToHighlightedComment() {
final key = commentKeys[widget.highlightedCommentId];
if (key != null && key.currentContext != null) {
Scrollable.ensureVisible(
key.currentContext!,
duration: const Duration(milliseconds: 500),
curve: Curves.easeInOut,
);
}
}
@override
Widget build(BuildContext context) {
final s = S.of(context);
@@ -49,8 +95,10 @@ class _CommentsWidgetState extends State<CommentsWidget> {
}
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;
return GestureDetector(
key: key,
onTap: () {
showCommentsActionsBottomSheet(
context,
@@ -83,7 +131,7 @@ class _CommentsWidgetState extends State<CommentsWidget> {
child: Container(
width: MediaQuery.of(context).size.width,
decoration: BoxDecoration(
color: widget.replyId == 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(

File diff suppressed because it is too large Load Diff

View File

@@ -267,33 +267,113 @@ class NotificationsCubit extends Cubit<NotificationsState> {
isRead: isRead,
createdAt: createdAt,
),
NotificationType.unknown => UnknownNotification(type: type),
NotificationType.consultantEducationUpdated => ConsultantEducationUpdatedNotification.fromJson(
notificationJson,
type,
isRead: isRead,
createdAt: createdAt,
),
NotificationType.complaintRegistered => ComplaintRegisteredNotification.fromJson(
notificationJson,
type,
isRead: isRead,
createdAt: createdAt,
),
NotificationType.contentUnpublished => ContentUnpublishedNotification.fromJson(
notificationJson,
type,
isRead: isRead,
createdAt: createdAt,
),
NotificationType.contentUnpublishedComplaint => ContentUnpublishedNotification.fromJson(
notificationJson,
type,
isRead: isRead,
createdAt: createdAt,
),
NotificationType.financeQuestionCreated => FinanceQuestionCreatedNotification.fromJson(
notificationJson,
type,
isRead: isRead,
createdAt: createdAt,
),
NotificationType.newChatMessage => NewChatMessageNotification.fromJson(
notificationJson,
type,
isRead: isRead,
createdAt: createdAt,
),
NotificationType.completeRegistration => CompleteRegistrationNotification.fromJson(
notificationJson,
type,
isRead: isRead,
createdAt: createdAt,
),
NotificationType.chosenDietExpired => ChosenDietExpiredNotification.fromJson(
notificationJson,
type,
isRead: isRead,
createdAt: createdAt,
),
NotificationType.motivateUser => MotivateUserNotification.fromJson(
notificationJson,
type,
isRead: isRead,
createdAt: createdAt,
),
NotificationType.commentComplaint => CommentComplaintNotification.fromJson(
notificationJson,
type,
isRead: isRead,
createdAt: createdAt,
),
NotificationType.commentDeleted => CommentDeletedNotification.fromJson(
notificationJson,
type,
isRead: isRead,
createdAt: createdAt,
),
NotificationType.userSubscribed => UserSubscribedNotification.fromJson(
notificationJson,
type,
isRead: isRead,
createdAt: createdAt,
),
NotificationType.unknown => UnknownNotification(type: type, createdAt: parseDate(createdAt ?? jsonEncode(notificationJson))),
};
} catch (e) {
return UnknownNotification(type: NotificationType.unknown);
return UnknownNotification(type: NotificationType.unknown, createdAt: DateTime.now());
}
}
void _handleNotification(String notificationData) {
final json = jsonDecode(notificationData) as Map<String, dynamic>;
// Улучшенная логика определения типа уведомления
// Улучшенная логика определения типа уведомления (порядок: database_type → type → notification_class_name)
String? stringifiedType;
// Сначала проверяем поле 'type' в корне JSON
if (json.containsKey('type') && json['type'] != null) {
// Сначала проверяем поле 'database_type' в корне JSON
if (json.containsKey('database_type') && json['database_type'] != null) {
stringifiedType = json['database_type'].toString();
log('Found database_type in root: $stringifiedType');
}
// Затем поле 'type' в корне
else if (json.containsKey('type') && json['type'] != null) {
stringifiedType = json['type'].toString();
log('Found type in root: $stringifiedType');
}
// Затем проверяем поле 'notification_class_name' в корне JSON
// Затем поле 'notification_class_name' в корне
else if (json.containsKey('notification_class_name') && json['notification_class_name'] != null) {
stringifiedType = json['notification_class_name'].toString();
log('Found notification_class_name in root: $stringifiedType');
}
// Если есть поле 'data', проверяем внутри него
// Если есть поле 'data', проверяем внутри него в том же порядке
else if (json.containsKey('data') && json['data'] is Map<String, dynamic>) {
final data = json['data'] as Map<String, dynamic>;
if (data.containsKey('type') && data['type'] != null) {
if (data.containsKey('database_type') && data['database_type'] != null) {
stringifiedType = data['database_type'].toString();
log('Found database_type in data: $stringifiedType');
} else if (data.containsKey('type') && data['type'] != null) {
stringifiedType = data['type'].toString();
log('Found type in data: $stringifiedType');
} else if (data.containsKey('notification_class_name') && data['notification_class_name'] != null) {

View File

@@ -413,6 +413,78 @@ class NotificationsService {
isRead: readDate != null,
createdAt: createdAt,
),
NotificationType.consultantEducationUpdated => ConsultantEducationUpdatedNotification.fromJson(
notificationJson['data'],
type,
isRead: readDate != null,
createdAt: createdAt,
),
NotificationType.complaintRegistered => ComplaintRegisteredNotification.fromJson(
notificationJson['data'],
type,
isRead: readDate != null,
createdAt: createdAt,
),
NotificationType.contentUnpublished => ContentUnpublishedNotification.fromJson(
notificationJson['data'],
type,
isRead: readDate != null,
createdAt: createdAt,
),
NotificationType.contentUnpublishedComplaint => ContentUnpublishedNotification.fromJson( // Reusing ContentUnpublishedNotification if structure is same, or create specific if different. Assuming similar for now or check backend.
notificationJson['data'],
type,
isRead: readDate != null,
createdAt: createdAt,
),
NotificationType.financeQuestionCreated => FinanceQuestionCreatedNotification.fromJson(
notificationJson['data'],
type,
isRead: readDate != null,
createdAt: createdAt,
),
NotificationType.newChatMessage => NewChatMessageNotification.fromJson(
notificationJson['data'],
type,
isRead: readDate != null,
createdAt: createdAt,
),
NotificationType.completeRegistration => CompleteRegistrationNotification.fromJson(
notificationJson['data'],
type,
isRead: readDate != null,
createdAt: createdAt,
),
NotificationType.chosenDietExpired => ChosenDietExpiredNotification.fromJson(
notificationJson['data'],
type,
isRead: readDate != null,
createdAt: createdAt,
),
NotificationType.motivateUser => MotivateUserNotification.fromJson(
notificationJson['data'],
type,
isRead: readDate != null,
createdAt: createdAt,
),
NotificationType.commentComplaint => CommentComplaintNotification.fromJson(
notificationJson['data'],
type,
isRead: readDate != null,
createdAt: createdAt,
),
NotificationType.commentDeleted => CommentDeletedNotification.fromJson(
notificationJson['data'],
type,
isRead: readDate != null,
createdAt: createdAt,
),
NotificationType.userSubscribed => UserSubscribedNotification.fromJson(
notificationJson['data'],
type,
isRead: readDate != null,
createdAt: createdAt,
),
NotificationType.unknown => UnknownNotification(type: type, createdAt: parseDate(createdAt)),
};
} catch (e) {

View File

@@ -32,10 +32,18 @@ import 'package:go_router/go_router.dart';
import '../../../../core/widgets/option_widget.dart';
class AboutPlanScreen extends StatefulWidget {
const AboutPlanScreen({super.key, required this.slug, required this.navigateToComments, required this.index});
final String slug;
final bool navigateToComments;
final int? index;
final int? highlightedCommentId;
const AboutPlanScreen({
super.key,
required this.slug,
required this.navigateToComments,
required this.index,
this.highlightedCommentId,
});
@override
State<AboutPlanScreen> createState() => _AboutPlanScreenState();
}
@@ -468,13 +476,14 @@ class _AboutPlanScreenState extends State<AboutPlanScreen> {
),
const SizedBox(height: 16),
if (commentsState is CommentsStateLoading) const Loader(),
if (commentsState is CommentsStateLoaded)
CommentsWidget(
id: state.planModel?.id.toString() ?? '',
commentSourceType: CommentSourceType.plan,
comment: commentsState.comment,
replyId: replyId,
),
if (commentsState is CommentsStateLoaded)
CommentsWidget(
id: state.planModel?.id.toString() ?? '',
commentSourceType: CommentSourceType.plan,
comment: commentsState.comment,
replyId: replyId,
highlightedCommentId: widget.highlightedCommentId,
),
],
),
),

View File

@@ -4,6 +4,7 @@ import 'package:eatvio_app/core/app_router.dart';
import 'package:eatvio_app/core/image_compressing/image_compress_utility.dart';
import 'package:eatvio_app/core/utils/toasts.dart';
import 'package:eatvio_app/features/chat/repositories/chat_repository.dart';
import 'package:eatvio_app/features/consultant/become_consultant/models/consultant_cert_photo_model.dart';
import 'package:eatvio_app/features/consultant/become_consultant/repositories/consultant_repository.dart';
import 'package:eatvio_app/features/feed/models/feed_blog_post_model.dart';
import 'package:eatvio_app/features/profile/presentation/bloc/profile_cubit/profile_state.dart';
@@ -24,13 +25,20 @@ class ProfileCubit extends Cubit<ProfileState> {
try {
final profile = await profileRepository.getProfileBySlug(slug);
final myProfile = await profileRepository.getMyProfile();
final isMyProfile = myProfile.slug == profile.slug;
List<ConsultantCertPhotoModel> certPhotos = [];
if (isMyProfile) {
certPhotos = await consultantRepository.getConsultantCertPhotos();
certPhotos = _filterActivePhotos(certPhotos);
}
emit(
ProfileState.loaded(
profile: profile,
isMyProfile: myProfile.slug == profile.slug,
isMyProfile: isMyProfile,
profileDetails: await profileRepository.getProfileDetailsBySlug(slug),
blogPosts: await profileRepository.getBlogPostsByAuthorSlug(slug: slug),
consultantInfo: profile.roles.contains('consultant') ? await consultantRepository.getConsultantRequestStatus(slug) : null,
certPhotos: certPhotos,
),
);
} catch (e) {
@@ -44,6 +52,8 @@ class ProfileCubit extends Cubit<ProfileState> {
_currentPeriod = period;
try {
final profile = await profileRepository.getMyProfile();
List<ConsultantCertPhotoModel> certPhotos = await consultantRepository.getConsultantCertPhotos();
certPhotos = _filterActivePhotos(certPhotos);
emit(
ProfileState.loaded(
profile: profile,
@@ -53,6 +63,7 @@ class ProfileCubit extends Cubit<ProfileState> {
weightPoints: await profileRepository.getWeight(period: period),
circumferencePoints: await profileRepository.getCircumferences(period: period),
consultantInfo: profile.roles.contains('consultant') ? await consultantRepository.getConsultantRequestStatus(profile.slug) : null,
certPhotos: certPhotos,
),
);
} catch (e, stackTrace) {
@@ -77,6 +88,7 @@ class ProfileCubit extends Cubit<ProfileState> {
circumferencePoints: (state as ProfileStateLoaded).circumferencePoints,
consultantInfo: (state as ProfileStateLoaded).consultantInfo,
isMyProfile: (state as ProfileStateLoaded).isMyProfile,
certPhotos: (state as ProfileStateLoaded).certPhotos,
),
);
}
@@ -131,4 +143,36 @@ class ProfileCubit extends Cubit<ProfileState> {
await profileRepository.deletePhoto();
return await getMyProfile();
}
Future<void> hideCertPhoto(int id) async {
try {
await consultantRepository.hideConsultantCertPhoto(id);
List<ConsultantCertPhotoModel> certPhotos = await consultantRepository.getConsultantCertPhotos();
certPhotos = _filterActivePhotos(certPhotos);
final currentState = state;
if (currentState is ProfileStateLoaded) {
emit(currentState.copyWith(certPhotos: certPhotos));
}
} catch (e) {
log(e.toString());
}
}
Future<void> unhideCertPhoto(int id) async {
try {
await consultantRepository.unHideConsultantCertPhoto(id);
List<ConsultantCertPhotoModel> certPhotos = await consultantRepository.getConsultantCertPhotos();
certPhotos = _filterActivePhotos(certPhotos);
final currentState = state;
if (currentState is ProfileStateLoaded) {
emit(currentState.copyWith(certPhotos: certPhotos));
}
} catch (e) {
log(e.toString());
}
}
List<ConsultantCertPhotoModel> _filterActivePhotos(List<ConsultantCertPhotoModel> photos) {
return photos.where((photo) => photo.isActive).toList();
}
}

View File

@@ -1,3 +1,4 @@
import 'package:eatvio_app/features/consultant/become_consultant/models/consultant_cert_photo_model.dart';
import 'package:eatvio_app/features/consultant/become_consultant/models/consultation_request_model.dart';
import 'package:eatvio_app/features/feed/models/feed_blog_post_model.dart';
import 'package:eatvio_app/features/profile/models/circumference_model.dart';
@@ -18,6 +19,7 @@ abstract class ProfileState with _$ProfileState {
required ProfileDetailsModel profileDetails,
required List<FeedBlogPostModel> blogPosts,
required bool isMyProfile,
required List<ConsultantCertPhotoModel> certPhotos,
List<WeightModel>? weightPoints,
List<CircumferenceModel>? circumferencePoints,
ConsultationRequestModel? consultantInfo,

View File

@@ -5,22 +5,30 @@ import 'package:flutter_bloc/flutter_bloc.dart';
class SubCubit extends Cubit<SubState> {
final ProfileRepository profileRepository;
SubCubit({
required this.profileRepository,
}) : super(const SubState());
SubCubit({required this.profileRepository}) : super(const SubState());
Future<void> getSubscriptions(String slug, [String? query]) async {
Future<void> getSubscriptions(String? slug, [String? query]) async {
emit(const SubState.loading());
try {
if (slug == null) {
final myProfile = await profileRepository.getMyProfile();
emit(SubState.loaded(subList: await profileRepository.getSubscriptionsBySlug(myProfile.slug, query)));
return;
}
emit(SubState.loaded(subList: await profileRepository.getSubscriptionsBySlug(slug, query)));
} catch (e) {
emit(SubState.error(errorMessage: e.toString()));
}
}
Future<void> getSubscribers(String slug, [String? query]) async {
Future<void> getSubscribers(String? slug, [String? query]) async {
emit(const SubState.loading());
try {
if (slug == null) {
final myProfile = await profileRepository.getMyProfile();
emit(SubState.loaded(subList: await profileRepository.getSubscribersBySlug(myProfile.slug, query)));
return;
}
emit(SubState.loaded(subList: await profileRepository.getSubscribersBySlug(slug, query)));
} catch (e) {
emit(SubState.error(errorMessage: e.toString()));

View File

@@ -8,6 +8,7 @@ import 'package:eatvio_app/core/enums/complained_content.dart';
import 'package:eatvio_app/core/permission_handler/permission_handler.dart';
import 'package:eatvio_app/core/theme/custom_text_style.dart';
import 'package:eatvio_app/core/utils/date_time_utils.dart';
import 'package:eatvio_app/core/widgets/bottom_sheet_with_blur.dart';
import 'package:eatvio_app/core/widgets/data_error_widget.dart';
import 'package:eatvio_app/core/widgets/loader.dart';
import 'package:eatvio_app/features/bottom_navigation_bar/presentation/bloc/profile_avatar_cubit/profile_avatar_cubit.dart';
@@ -26,6 +27,7 @@ import 'package:eatvio_app/features/profile/presentation/pages/subs_screen.dart'
import 'package:eatvio_app/features/profile/presentation/widgets/circumference_chart_widget.dart';
import 'package:eatvio_app/features/profile/presentation/widgets/imt_chart_widget.dart';
import 'package:eatvio_app/features/profile/presentation/widgets/profile_blog_post_widget.dart';
import 'package:eatvio_app/features/profile/presentation/widgets/toggle_cert_photo_bottom_sheet.dart';
import 'package:eatvio_app/features/profile/presentation/widgets/weight_chart_widget.dart';
import 'package:eatvio_app/core/locator_service.dart';
import 'package:eatvio_app/core/theme/theme_helper.dart';
@@ -117,7 +119,6 @@ class _ProfileScreenState extends State<ProfileScreen> {
),
);
}
return state.isMyProfile ? _myProfileScreen(state) : _profileScreen(state);
} else if (state is ProfileStateError) {
return Scaffold(
@@ -436,9 +437,16 @@ class _ProfileScreenState extends State<ProfileScreen> {
}
SingleChildRenderObjectWidget _diplomasAndCertificates(ProfileStateLoaded state) {
if (!state.profile.roles.contains('consultant') || state.consultantInfo!.photos.isEmpty) {
if (!state.profile.roles.contains('consultant')) {
return const SizedBox.shrink();
}
if (state.isMyProfile && state.certPhotos.isEmpty) {
return const SizedBox.shrink();
}
if (!state.isMyProfile && state.consultantInfo!.photos.isEmpty) {
return const SizedBox.shrink();
}
return Padding(
padding: const EdgeInsets.only(bottom: 16),
child: Column(
@@ -454,7 +462,7 @@ class _ProfileScreenState extends State<ProfileScreen> {
),
GestureDetector(
onTap: () {
GoRouter.of(context).pushNamed('stepperBecomeConsultantScreen', extra: state.profile.slug);
GoRouter.of(context).pushNamed('stepperBecomeConsultantScreen', extra: {'slug': state.profile.slug});
},
child: Text(
state.isMyProfile ? 'Редактировать' : '',
@@ -477,14 +485,15 @@ class _ProfileScreenState extends State<ProfileScreen> {
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
children: [
if (state.consultantInfo!.photos.isNotEmpty)
...List.generate(state.consultantInfo!.photos.length * 2 - 1, (index) {
if (index.isEven) {
final photo = state.consultantInfo!.photos[index ~/ 2];
return _diplomaImage(photo.url);
} else {
return const SizedBox(width: 8);
}
if (state.isMyProfile && state.certPhotos.isNotEmpty)
...List.generate(state.certPhotos.length, (index) {
final photo = state.certPhotos[index];
return Row(children: [_diplomaImage(photo.url, state.isMyProfile, photo.isHidden, photo.id), const SizedBox(width: 8)]);
})
else if (state.consultantInfo!.photos.isNotEmpty)
...List.generate(state.consultantInfo!.photos.length, (index) {
final photo = state.consultantInfo!.photos[index];
return Row(children: [_diplomaImage(photo.url, state.isMyProfile, false, null), const SizedBox(width: 8)]);
}),
],
),
@@ -496,16 +505,49 @@ class _ProfileScreenState extends State<ProfileScreen> {
);
}
GestureDetector _diplomaImage(String url) {
GestureDetector _diplomaImage(String url, bool isMyProfile, bool isHidden, int? certPhotoId) {
return GestureDetector(
onTap: () {
context.pushTransparentRoute(rootNavigator: true, PhotoViewScreen(imageUrls: [url], index: 0));
},
child: Hero(
tag: url,
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: CachedNetworkImage(imageUrl: url, fit: BoxFit.fitHeight, width: MediaQuery.of(context).size.width / 3 - (60 / 3), height: 160),
child: Stack(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: CachedNetworkImage(imageUrl: url, fit: BoxFit.fill, width: MediaQuery.of(context).size.width / 3 - (60 / 3), height: 160),
),
if (isMyProfile)
Positioned(
right: 4,
top: 4,
child: GestureDetector(
onTap: () {
if (certPhotoId != null) {
showBottomSheetWithBlur(
userRootNavigator: true,
context: context,
height: 250,
child: ToggleCertPhotoBottomSheet(profileCubit: profileCubit, isPhotoHidden: isHidden, id: certPhotoId),
);
}
},
child: Container(
decoration: BoxDecoration(color: appTheme.whiteA700, borderRadius: BorderRadius.circular(4)),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 5),
child: SvgPicture.asset(
isHidden ? 'assets/icons/crossed_eye.svg' : 'assets/icons/eye2.svg',
width: 10,
height: 8,
colorFilter: ColorFilter.mode(appTheme.blueA400, BlendMode.srcIn),
),
),
),
),
),
],
),
),
);
@@ -532,7 +574,7 @@ class _ProfileScreenState extends State<ProfileScreen> {
),
GestureDetector(
onTap: () {
GoRouter.of(context).pushNamed('stepperBecomeConsultantScreen', extra: state.profile.slug);
GoRouter.of(context).pushNamed('stepperBecomeConsultantScreen', extra: {'slug': state.profile.slug});
},
child: Text(
state.isMyProfile ? 'Редактировать' : '',

View File

@@ -12,7 +12,7 @@ enum SubType { subscribers, subscriptions }
class SubsScreen extends StatefulWidget {
final SubType subType;
final String slug;
final String? slug;
const SubsScreen({
super.key,

View File

@@ -0,0 +1,55 @@
import 'package:eatvio_app/core/app_router.dart';
import 'package:eatvio_app/core/theme/custom_text_style.dart';
import 'package:eatvio_app/core/theme/theme_helper.dart';
import 'package:eatvio_app/core/widgets/bottom_sheet_handler_bar.dart';
import 'package:eatvio_app/core/widgets/custom_outlined_button.dart';
import 'package:eatvio_app/features/profile/presentation/bloc/profile_cubit/profile_cubit.dart';
import 'package:eatvio_app/generated/l10n.dart';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
class ToggleCertPhotoBottomSheet extends StatelessWidget {
const ToggleCertPhotoBottomSheet({super.key, required this.profileCubit, required this.isPhotoHidden, required this.id});
final ProfileCubit profileCubit;
final bool isPhotoHidden;
final int id;
@override
Widget build(BuildContext context) {
final s = S.of(context);
return Column(
children: [
const SizedBox(height: 20),
const BottomSheetHandlerBar(),
const SizedBox(height: 20),
Text(isPhotoHidden ? s.unhide_photo_title : s.hide_photo_title, style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black)),
Text(
isPhotoHidden ? s.unhide_photo_description : s.hide_photo_description,
style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black),
textAlign: TextAlign.center,
),
const SizedBox(height: 20),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: CustomOutlinedButton(
onPressed: () async {
if (isPhotoHidden) {
await profileCubit.unhideCertPhoto(id);
AppRouter.navigatorKey.currentContext?.pop();
return;
}
await profileCubit.hideCertPhoto(id);
AppRouter.navigatorKey.currentContext?.pop();
return;
},
text: s.ok,
height: 52,
buttonTextStyle: TextStyles.textSSemibold.copyWith(color: appTheme.whiteA700),
buttonStyle: ButtonStyle(side: const WidgetStatePropertyAll(BorderSide.none), backgroundColor: WidgetStatePropertyAll(appTheme.blueA400)),
),
),
const SizedBox(height: 20),
],
);
}
}

View File

@@ -36,8 +36,9 @@ class AboutRecipeScreen extends StatefulWidget {
final String recipeSlug;
final int? index;
final bool navigateToComments;
final int? highlightedCommentId;
const AboutRecipeScreen({super.key, required this.recipeSlug, required this.index, required this.navigateToComments});
const AboutRecipeScreen({super.key, required this.recipeSlug, required this.index, required this.navigateToComments, this.highlightedCommentId});
@override
State<AboutRecipeScreen> createState() => _AboutRecipeScreenState();
@@ -391,6 +392,7 @@ class _AboutRecipeScreenState extends State<AboutRecipeScreen> {
commentSourceType: CommentSourceType.recipe,
comment: state.comment,
replyId: replyId,
highlightedCommentId: widget.highlightedCommentId,
),
],
),

View File

@@ -254,12 +254,15 @@ class _SettingsScreenState extends State<SettingsScreen> {
if (state.consultationRequestModel!.educationModel.requestState == ModerationStatus.created) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16) + const EdgeInsets.only(bottom: 8),
child: Ink(
height: 52,
width: MediaQuery.of(context).size.width - 16,
decoration: BoxDecoration(borderRadius: BorderRadius.circular(20), color: const Color(0xFFEAEAEA)),
child: const Center(
child: Text('Данные на проверке', style: TextStyle(color: Colors.black, height: 1, fontSize: 18)),
child: InkWell(
onTap: () => GoRouter.of(context).pushNamed('stepperBecomeConsultantScreen', extra: {'slug': state.profile.slug}),
child: Ink(
height: 52,
width: MediaQuery.of(context).size.width - 16,
decoration: BoxDecoration(borderRadius: BorderRadius.circular(20), color: const Color(0xFFEAEAEA)),
child: const Center(
child: Text('Данные на проверке', style: TextStyle(color: Colors.black, height: 1, fontSize: 18)),
),
),
),
);
@@ -267,7 +270,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16) + const EdgeInsets.only(bottom: 8),
child: InkWell(
onTap: () => GoRouter.of(context).pushNamed('stepperBecomeConsultantScreen', extra: state.profile.slug),
onTap: () => GoRouter.of(context).pushNamed('stepperBecomeConsultantScreen', extra: {'slug': state.profile.slug, 'isCanceled': true}),
borderRadius: BorderRadius.circular(20),
child: Ink(
height: 52,