feat(consultant): Issue-420 + Tests

This commit is contained in:
mso7nueh
2025-11-25 15:52:41 +03:00
parent 58bb766721
commit 2e57cd9519
14 changed files with 656 additions and 254 deletions

View File

@@ -1,4 +1,7 @@
import 'dart:io';
import 'package:eatvio_app/core/app_initializer.dart';
import 'package:eatvio_app/core/app_config.dart';
import 'package:eatvio_app/core/locator_service.dart';
import 'package:flutter_image_compress/flutter_image_compress.dart';
import 'package:image/image.dart' as img;
import 'package:path/path.dart' as p;
@@ -27,6 +30,17 @@ abstract class ImageCompressUtility {
}
static Future<File> compressFile(File file) async {
// Skip compression in test environment to avoid hangs
try {
final appConfig = sl<AppConfig>();
if (appConfig.buildType == AppBuildType.tests) {
// In tests, return the original file without compression
return file;
}
} catch (_) {
// If AppConfig is not available, proceed with compression
}
final image = img.decodeImage(file.readAsBytesSync())!;
int width = image.width;
int height = image.height;

View File

@@ -0,0 +1,27 @@
import 'package:freezed_annotation/freezed_annotation.dart';
part 'consultant_cert_photo_model.freezed.dart';
part 'consultant_cert_photo_model.g.dart';
@freezed
abstract class ConsultantCertPhotoModel with _$ConsultantCertPhotoModel {
const factory ConsultantCertPhotoModel({
required int id,
@JsonKey(name: 'user_id') int? userId,
required String group,
@JsonKey(name: 'original_name') required String originalName,
required String path,
required int size,
Map<String, dynamic>? specs,
required String url,
}) = _ConsultantCertPhotoModel;
const ConsultantCertPhotoModel._();
factory ConsultantCertPhotoModel.fromJson(Map<String, dynamic> json) => _$ConsultantCertPhotoModelFromJson(json);
static const String visibleGroup = 'user_certs';
static const String hiddenGroup = 'hidden_user_certs';
bool get isHidden => group == hiddenGroup;
}

View File

@@ -1,59 +1,33 @@
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';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:http/http.dart' as http;
import 'package:path_provider/path_provider.dart';
class RegisterConsultantCubit extends Cubit<RegisterConsultantState> {
final ConsultantRepository consultantRepository;
bool _update = false;
RegisterConsultantCubit({
required this.consultantRepository,
}) : super(
const RegisterConsultantState(
consultant: ConsultantProfileModel(
socialLinks: SocialLinksModel(),
hasMedicalEducation: true,
),
loading: true,
acceptOffer: false,
),
);
Future<File> downloadImageAsFile(String imageUrl) async {
final response = await http.get(Uri.parse(imageUrl));
if (response.statusCode == 200) {
final tempDir = await getTemporaryDirectory();
final filePath = '${tempDir.path}/${imageUrl.split('/').last}';
final file = File(filePath);
return await file.writeAsBytes(response.bodyBytes);
} else {
throw Exception('Не удалось загрузить изображение');
}
}
RegisterConsultantCubit({required this.consultantRepository})
: super(
const RegisterConsultantState(
consultant: ConsultantProfileModel(socialLinks: SocialLinksModel(), hasMedicalEducation: true),
loading: true,
acceptOffer: false,
),
);
Future<void> getMyRequestStatus(String slug) async {
_update = true;
final myRequestStatus = await consultantRepository.getMyRequestStatus(slug);
final List<String> photos = await Future.wait(
myRequestStatus.photos.map(
(e) async => (await downloadImageAsFile(e.url)).path,
),
);
final certPhotos = await _fetchVisibleCertPhotos();
List<CertificatesModel>? certificates = myRequestStatus.educationModel.completedCourses?.map((courseMap) {
final course = courseMap.values.first;
return CertificatesModel(
title: course.title,
date: course.date,
);
return CertificatesModel(title: course.title, date: course.date);
}).toList();
String? sanitizeLink(String? link) {
@@ -63,7 +37,6 @@ class RegisterConsultantCubit extends Cubit<RegisterConsultantState> {
emit(
RegisterConsultantState(
consultant: ConsultantProfileModel(
photos: photos,
certificates: certificates,
socialLinks: SocialLinksModel(
vkLink: sanitizeLink(myRequestStatus.educationModel.vkLink),
@@ -77,38 +50,44 @@ class RegisterConsultantCubit extends Cubit<RegisterConsultantState> {
acceptOffer: false,
loading: false,
requestModel: myRequestStatus,
certPhotos: certPhotos,
),
);
}
void disableLoading() {
emit(state.copyWith(loading: false));
Future<void> disableLoading() async {
final certPhotos = await _fetchVisibleCertPhotos();
emit(state.copyWith(loading: false, certPhotos: certPhotos));
}
void updateConsultantProfileModel(ConsultantProfileModel consultant, [bool acceptOffer = false]) {
emit(
state.copyWith(
consultant: consultant,
acceptOffer: acceptOffer,
loading: false,
),
);
emit(state.copyWith(consultant: consultant, acceptOffer: acceptOffer, loading: false));
}
Future<void> registerConsultant() async {
if (state.consultant.photos != null) {
updateConsultantProfileModel(
state.consultant.copyWith(
photos: await uploadPhotos(
state.consultant.photos!.map((e) => File(e)).toList(),
),
),
);
}
await consultantRepository.registerConsultant(consultant: state.consultant, update: _update);
}
Future<List<String>> uploadPhotos(List<File> files) async {
return await consultantRepository.uploadPhotos(files: await ImageCompressUtility.compressFilesList(files));
Future<void> uploadCertPhotos(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)));
}
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 {
final photos = await consultantRepository.getConsultantCertPhotos();
return _onlyVisible(photos);
}
List<ConsultantCertPhotoModel> _onlyVisible(List<ConsultantCertPhotoModel> photos) {
return photos.where((photo) => !photo.isHidden).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/consultant_profile_model.dart';
import 'package:eatvio_app/features/consultant/become_consultant/models/consultation_request_model.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
@@ -11,5 +12,6 @@ abstract class RegisterConsultantState with _$RegisterConsultantState {
required bool acceptOffer,
required bool loading,
ConsultationRequestModel? requestModel,
@Default(<ConsultantCertPhotoModel>[]) List<ConsultantCertPhotoModel> certPhotos,
}) = _RegisterConsultantState;
}

View File

@@ -1,9 +1,11 @@
import 'dart:io';
import 'package:dismissible_page/dismissible_page.dart';
import 'package:eatvio_app/features/chat/presentation/pages/photo_view_screen.dart';
import 'package:eatvio_app/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_cubit.dart';
import 'package:eatvio_app/core/theme/theme_helper.dart';
import 'package:eatvio_app/features/chat/presentation/pages/photo_view_screen.dart';
import 'package:eatvio_app/features/consultant/become_consultant/models/consultant_cert_photo_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:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:image_picker/image_picker.dart';
@@ -16,14 +18,7 @@ class PhotoOrScansScreen extends StatefulWidget {
}
class _PhotoOrScansScreenState extends State<PhotoOrScansScreen> {
List<String> imagesPaths = [];
@override
void initState() {
super.initState();
imagesPaths =
context.read<RegisterConsultantCubit>().state.consultant.photos != null ? [...context.read<RegisterConsultantCubit>().state.consultant.photos!] : [];
}
bool isUploading = false;
@override
Widget build(BuildContext context) {
@@ -34,125 +29,120 @@ class _PhotoOrScansScreenState extends State<PhotoOrScansScreen> {
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Загрузите фото или сканы дипломов и сертификатов',
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.w700,
height: 1.1,
),
),
const Text('Загрузите фото или сканы дипломов и сертификатов', style: TextStyle(fontSize: 24, fontWeight: FontWeight.w700, height: 1.1)),
const SizedBox(height: 16),
GridView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 3,
childAspectRatio: 3 / 4,
crossAxisSpacing: 8,
mainAxisSpacing: 8,
),
itemBuilder: (BuildContext context, int index) {
if (index == imagesPaths.length) {
return InkWell(
borderRadius: BorderRadius.circular(20),
onTap: () async {
final picker = ImagePicker();
List<XFile>? newImages = await picker.pickMultiImage();
List<String> newImagesPaths = newImages.map((e) => e.path).toList();
setState(() {
imagesPaths = [...imagesPaths, ...newImagesPaths];
});
_saveState();
},
child: Ink(
decoration: BoxDecoration(
color: const Color(0xFFF6F6F6),
borderRadius: BorderRadius.circular(20),
BlocBuilder<RegisterConsultantCubit, RegisterConsultantState>(
builder: (context, state) {
final photos = state.certPhotos;
return Column(
children: [
if (isUploading) ...[const LinearProgressIndicator(), const SizedBox(height: 16)],
GridView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 3,
childAspectRatio: 3 / 4,
crossAxisSpacing: 8,
mainAxisSpacing: 8,
),
child: Icon(
Icons.add,
color: appTheme.blueA400,
),
),
);
} else {
return Hero(
tag: imagesPaths[index],
child: Material(
borderRadius: BorderRadius.circular(20),
child: InkWell(
onTap: () {
context.pushTransparentRoute(
rootNavigator: true,
PhotoViewScreen(
imageUrls: imagesPaths,
isLocal: true,
index: index,
itemBuilder: (BuildContext context, int index) {
if (index == photos.length) {
return InkWell(
borderRadius: BorderRadius.circular(20),
onTap: () => _pickAndUploadPhotos(),
child: Ink(
decoration: BoxDecoration(color: const Color(0xFFF6F6F6), borderRadius: BorderRadius.circular(20)),
child: Icon(Icons.add, color: appTheme.blueA400),
),
);
},
borderRadius: BorderRadius.circular(20),
child: Stack(
clipBehavior: Clip.none,
children: [
Ink(
decoration: BoxDecoration(
borderRadius: const BorderRadius.all(
Radius.circular(20),
),
image: DecorationImage(
fit: BoxFit.fitHeight,
image: FileImage(
File(imagesPaths[index]),
),
),
),
),
Positioned(
right: -12,
top: -12,
child: InkWell(
onTap: () {
setState(() {
imagesPaths.removeAt(index);
});
_saveState();
},
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,
),
),
),
),
],
),
),
} else {
return _CertPhotoCard(photo: photos[index], allPhotos: photos, onRemove: () => _removePhoto(photos[index].id));
}
},
itemCount: photos.length + 1,
),
);
}
],
);
},
itemCount: imagesPaths.length + 1,
),
],
),
);
}
void _saveState() {
context.read<RegisterConsultantCubit>().updateConsultantProfileModel(
context.read<RegisterConsultantCubit>().state.consultant.copyWith(
photos: imagesPaths,
),
);
Future<void> _pickAndUploadPhotos() async {
final picker = ImagePicker();
final newImages = await picker.pickMultiImage();
if (newImages.isEmpty) {
return;
}
setState(() {
isUploading = true;
});
try {
await context.read<RegisterConsultantCubit>().uploadCertPhotos(newImages.map((file) => File(file.path)).toList());
} finally {
if (mounted) {
setState(() {
isUploading = false;
});
}
}
}
Future<void> _removePhoto(int id) async {
await context.read<RegisterConsultantCubit>().removeCertPhoto(id);
}
}
class _CertPhotoCard extends StatelessWidget {
final ConsultantCertPhotoModel photo;
final List<ConsultantCertPhotoModel> allPhotos;
final VoidCallback onRemove;
const _CertPhotoCard({required this.photo, required this.allPhotos, required this.onRemove});
@override
Widget build(BuildContext context) {
return Hero(
tag: photo.id,
child: Material(
borderRadius: BorderRadius.circular(20),
child: InkWell(
onTap: () {
context.pushTransparentRoute(
rootNavigator: true,
PhotoViewScreen(imageUrls: allPhotos.map((e) => e.url).toList(), isLocal: false, 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: NetworkImage(photo.url)),
),
),
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,62 +1,28 @@
import 'dart:io';
import 'package:dio/dio.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/models/consultation_request_model.dart';
import 'package:eatvio_app/features/consultant/become_consultant/services/consultant_service.dart';
import 'package:http_parser/http_parser.dart';
import 'package:dio/dio.dart';
class ConsultantRepository {
final ConsultantService consultantService;
const ConsultantRepository({
required this.consultantService,
});
const ConsultantRepository({required this.consultantService});
Future<void> registerConsultant({
required ConsultantProfileModel consultant,
bool update = false,
}) async {
Future<void> registerConsultant({required ConsultantProfileModel consultant, bool update = false}) async {
try {
if (update) {
return await consultantService.consultantUpdate(
consultantModel: consultant,
);
return await consultantService.consultantUpdate(consultantModel: consultant);
} else {
return await consultantService.consultantRegistration(
consultantModel: consultant,
);
return await consultantService.consultantRegistration(consultantModel: consultant);
}
} catch (e) {
rethrow;
}
}
Future<List<String>> uploadPhotos({
required List<File> files,
}) async {
try {
final multipartFiles = <MultipartFile>[];
for (final file in files) {
final fileBytes = await file.readAsBytes();
final multipartFile = MultipartFile.fromBytes(
fileBytes,
filename: file.path
.split('/')
.last,
contentType: MediaType('application', 'octet-stream'),
);
multipartFiles.add(multipartFile);
}
return await consultantService.uploadPhotos(
photos: multipartFiles.isEmpty ? null : multipartFiles,
);
} catch (e) {
rethrow;
}
}
Future<ConsultationRequestModel> getConsultantRequestStatus(String slug) async {
try {
return await consultantService.getConsultantRequestStatus(slug: slug);
@@ -72,4 +38,48 @@ class ConsultantRepository {
rethrow;
}
}
Future<List<ConsultantCertPhotoModel>> getConsultantCertPhotos() async {
try {
return await consultantService.getConsultantCertPhotos();
} catch (e) {
rethrow;
}
}
Future<List<ConsultantCertPhotoModel>> storeConsultantCertPhotos({required List<File> files}) async {
try {
final multipartFiles = <MultipartFile>[];
for (final file in files) {
final fileBytes = await file.readAsBytes();
final multipartFile = MultipartFile.fromBytes(fileBytes, filename: file.path.split('/').last, contentType: MediaType('application', 'octet-stream'));
multipartFiles.add(multipartFile);
}
if (multipartFiles.isEmpty) {
return [];
}
return await consultantService.storeConsultantCertPhotos(photos: multipartFiles);
} catch (e) {
rethrow;
}
}
Future<void> hideConsultantCertPhoto(int id) async {
try {
await consultantService.hideConsultantCertPhoto(payload: {'cert_photo_id': id});
} catch (e) {
rethrow;
}
}
Future<void> unHideConsultantCertPhoto(int id) async {
try {
await consultantService.unHideConsultantCertPhoto(payload: {'cert_photo_id': id});
} catch (e) {
rethrow;
}
}
}

View File

@@ -1,4 +1,5 @@
import 'package:dio/dio.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/models/consultation_request_model.dart';
import 'package:retrofit/retrofit.dart';
@@ -10,28 +11,33 @@ abstract class ConsultantService {
factory ConsultantService(Dio dio, {String baseUrl}) = _ConsultantService;
@POST('/api/consultant_request/registration')
Future<void> consultantRegistration({
@Body() required ConsultantProfileModel consultantModel,
});
Future<void> consultantRegistration({@Body() required ConsultantProfileModel consultantModel});
@POST('/api/consultant_request/photos/upload')
@MultiPart()
Future<List<String>> uploadPhotos({
@Part(name: 'photos[]', contentType: 'multipart/form-data') List<MultipartFile>? photos,
});
Future<List<String>> uploadPhotos({@Part(name: 'photos[]', contentType: 'multipart/form-data') List<MultipartFile>? photos});
@GET('/api/profile/consultant/{slug}/status')
Future<ConsultationRequestModel> getConsultantRequestStatus({
@Path() required String slug,
});
Future<ConsultationRequestModel> getConsultantRequestStatus({@Path() required String slug});
@GET('/api/consultant_request/{slug}/status')
Future<ConsultationRequestModel> getMyRequestStatus({
@Path() required String slug,
});
Future<ConsultationRequestModel> getMyRequestStatus({@Path() required String slug});
@PUT('/api/consultant_request/update')
Future<void> consultantUpdate({
@Body() required ConsultantProfileModel consultantModel,
Future<void> consultantUpdate({@Body() required ConsultantProfileModel consultantModel});
@GET('/api/v2/consultant_request/cert_photos')
Future<List<ConsultantCertPhotoModel>> getConsultantCertPhotos();
@POST('/api/v2/consultant_request/cert_photos/store')
@MultiPart()
Future<List<ConsultantCertPhotoModel>> storeConsultantCertPhotos({
@Part(name: 'photos[]', contentType: 'multipart/form-data') required List<MultipartFile> photos,
});
@POST('/api/v2/consultant_request/cert_photos/hide')
Future<void> hideConsultantCertPhoto({@Body() required Map<String, dynamic> payload});
@POST('/api/v2/consultant_request/cert_photos/un_hide')
Future<void> unHideConsultantCertPhoto({@Body() required Map<String, dynamic> payload});
}