Merge remote-tracking branch 'upstream/main' into issue-738

This commit is contained in:
misha
2025-11-19 11:16:45 +03:00
22 changed files with 631 additions and 241 deletions

View File

@@ -140,7 +140,21 @@ jobs:
run: flutter analyze
- name: Test
run: flutter test
run: flutter test --coverage
- name: Test coverage
run: |
awk -F: '
/^LF:/ { total += $2 }
/^LH:/ { covered += $2 }
END {
if (total == 0) {
print "Coverage: 0%"
} else {
printf "Coverage: %.2f%%\n", (covered / total) * 100
}
}
' coverage/lcov.info
- name: Build apk
run: |

View File

@@ -19,15 +19,15 @@ def yandexClientId = project.hasProperty('YANDEX_CLIENT_ID') ? project.property(
android {
namespace = "com.eatvioapp.app"
compileSdk = flutter.compileSdkVersion
ndkVersion = "27.0.12077973"
ndkVersion = flutter.ndkVersion
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = JavaVersion.VERSION_11.toString()
jvmTarget = JavaVersion.VERSION_17.toString()
}
defaultConfig {

View File

@@ -406,7 +406,7 @@
"$(inherited)",
"$(PROJECT_DIR)/Flutter",
);
MARKETING_VERSION = 1.3.3;
MARKETING_VERSION = 1.3.4;
PRODUCT_BUNDLE_IDENTIFIER = com.eatvioapp.app;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "match AppStore com.eatvioapp.app 1747050044";
@@ -557,7 +557,7 @@
"$(inherited)",
"$(PROJECT_DIR)/Flutter",
);
MARKETING_VERSION = 1.3.3;
MARKETING_VERSION = 1.3.4;
PRODUCT_BUNDLE_IDENTIFIER = com.eatvioapp.app;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "match AppStore com.eatvioapp.app 1747050044";
@@ -596,7 +596,7 @@
"$(inherited)",
"$(PROJECT_DIR)/Flutter",
);
MARKETING_VERSION = 1.3.3;
MARKETING_VERSION = 1.3.4;
PRODUCT_BUNDLE_IDENTIFIER = com.eatvioapp.app;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "match AppStore com.eatvioapp.app 1747050044";

View File

@@ -21,7 +21,7 @@
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>82</string>
<string>83</string>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>LSRequiresIPhoneOS</key>

View File

@@ -411,7 +411,7 @@ class _AuthScreenState extends State<AuthScreen> {
_isLoading = false;
});
},
label: state is AuthStateLoading
label: state is AuthStateLoading || _isLoading
? const CupertinoActivityIndicator()
: Text('Войти', style: theme.textTheme.titleSmall!.copyWith(color: Colors.white)),
),
@@ -426,8 +426,8 @@ class _AuthScreenState extends State<AuthScreen> {
elevation: 0,
focusElevation: 0,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
backgroundColor: state is AuthStateLoading ? Colors.black.withAlpha(50) : appTheme.blueA400,
onPressed: state is AuthStateLoading
backgroundColor: state is AuthStateLoading || _isLoading ? Colors.black.withAlpha(50) : appTheme.blueA400,
onPressed: state is AuthStateLoading || _isLoading
? null
: () async {
await showDialog(
@@ -487,7 +487,7 @@ class _AuthScreenState extends State<AuthScreen> {
},
);
},
label: state is AuthStateLoading
label: state is AuthStateLoading || _isLoading
? const CupertinoActivityIndicator()
: const Icon(Icons.qr_code_scanner_rounded, color: Colors.white),
),

View File

@@ -5,6 +5,7 @@ import 'package:eatvio_app/core/app_config.dart';
import 'package:eatvio_app/core/app_router.dart';
import 'package:eatvio_app/core/permission_handler/permission_handler.dart';
import 'package:eatvio_app/core/theme/app_decoration.dart';
import 'package:eatvio_app/core/widgets/loader.dart';
import 'package:eatvio_app/core/widgets/show_error_dialog.dart';
import 'package:eatvio_app/features/blog_post_create/bloc/blog_post_create_cubit/blog_post_create_cubit.dart';
import 'package:eatvio_app/features/blog_post_create/bloc/blog_post_create_cubit/blog_post_create_state.dart';
@@ -620,16 +621,16 @@ class _BlogPostCreateScreenState extends State<BlogPostCreateScreen> {
}
},
borderRadius: BorderRadius.circular(16),
child: Ink(
width: MediaQuery.of(context).size.width - 32,
height: 52,
decoration: BoxDecoration(borderRadius: BorderRadius.circular(16), color: appTheme.blueA400),
child: isLoading
? const Center(child: CircularProgressIndicator())
: const Center(
child: isLoading
? const Center(child: Loader())
: Ink(
width: MediaQuery.of(context).size.width - 32,
height: 52,
decoration: BoxDecoration(borderRadius: BorderRadius.circular(16), color: appTheme.blueA400),
child: const Center(
child: Text('Создать', style: TextStyle(color: Colors.white)),
),
),
),
);
}

View File

@@ -63,25 +63,28 @@ class _ConsultationCardWidgetState extends State<ConsultationCardWidget> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
CircleAvatar(radius: 30, backgroundImage: NetworkImage(widget.consultationsModel.userProfilePhotoUrl)),
const SizedBox(width: 12),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.consultationsModel.userName,
style: const TextStyle(height: 1, color: Color(0xFF323F4B), fontSize: 16, fontWeight: FontWeight.w600),
),
const SizedBox(height: 4),
Text(
widget.consultationsModel.planTitle,
style: const TextStyle(height: 1, color: Color(0xFF88949F), fontSize: 14, fontWeight: FontWeight.w500),
),
],
),
],
GestureDetector(
onTap: () => GoRouter.of(context).pushNamed('profileScreen', extra: widget.consultationsModel.userSlug),
child: Row(
children: [
CircleAvatar(radius: 30, backgroundImage: NetworkImage(widget.consultationsModel.userProfilePhotoUrl)),
const SizedBox(width: 12),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.consultationsModel.userName,
style: const TextStyle(height: 1, color: Color(0xFF323F4B), fontSize: 16, fontWeight: FontWeight.w600),
),
const SizedBox(height: 4),
Text(
widget.consultationsModel.planTitle,
style: const TextStyle(height: 1, color: Color(0xFF88949F), fontSize: 14, fontWeight: FontWeight.w500),
),
],
),
],
),
),
const SizedBox(height: 12),
GestureDetector(

View File

@@ -1,13 +1,8 @@
import 'dart:convert';
import 'dart:io';
import 'dart:typed_data';
import 'package:eatvio_app/core/enums/complained_content.dart';
import 'package:eatvio_app/core/enums/product_type.dart';
import 'package:eatvio_app/core/utils/localization_utils.dart';
import 'package:eatvio_app/core/utils/string_extentions.dart';
import 'package:eatvio_app/core/widgets/bookmark_button.dart';
import 'package:eatvio_app/core/widgets/custom_divider.dart';
import 'package:eatvio_app/core/widgets/custom_popup.dart';
import 'package:eatvio_app/core/widgets/info_row.dart';
import 'package:eatvio_app/features/diets/presentation/widgets/author_card.dart';
import 'package:eatvio_app/core/widgets/cpfc_container.dart';
@@ -36,20 +31,11 @@ class DietPreviewScreen extends StatefulWidget {
class _DietPreviewScreenState extends State<DietPreviewScreen> {
final ScrollController scrollController = ScrollController();
final textEditingController = TextEditingController();
bool isMoreOptionsVisible = false;
late final Uint8List imageData;
@override
void initState() {
super.initState();
imageData = base64.decode(widget.diet.image);
scrollController.addListener(() {
if (isMoreOptionsVisible) {
setState(() {
isMoreOptionsVisible = false;
});
}
});
}
@override
@@ -58,86 +44,6 @@ class _DietPreviewScreenState extends State<DietPreviewScreen> {
return Scaffold(
backgroundColor: appTheme.gray100,
resizeToAvoidBottomInset: true,
bottomSheet: Container(
padding: const EdgeInsets.symmetric(vertical: 12) + (Platform.isIOS ? const EdgeInsets.only(bottom: 16) : EdgeInsets.zero),
decoration: BoxDecoration(
color: Colors.white,
border: Border(top: BorderSide(color: Colors.black.withValues(alpha: 0.1))),
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
Expanded(
child: BookmarkButton(
isFavorite: widget.diet.isFavorite,
onTap: () {},
width: 52,
height: 52,
size: 24,
shape: BoxShape.circle,
color: appTheme.gray50,
),
),
],
),
const SizedBox(height: 8),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Expanded(
child: SizedBox(
child: TextFormField(
textCapitalization: TextCapitalization.sentences,
onChanged: (_) {
setState(() {});
},
controller: textEditingController,
maxLines: 5,
minLines: 1,
keyboardType: TextInputType.multiline,
style: TextStyles.textMMedium.copyWith(color: appTheme.black),
decoration: InputDecoration(
isDense: true,
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
hintText: s.comment.capitalize(),
hintStyle: TextStyles.textMMedium.copyWith(color: appTheme.lightGray600),
filled: true,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: const BorderSide(color: Color(0xFFD1D1D6), width: 1),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: const BorderSide(color: Color(0xFFD1D1D6), width: 1),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: const BorderSide(color: Color(0xFFD1D1D6), width: 1),
),
),
),
),
),
const SizedBox(width: 8),
GestureDetector(
onTap: () {},
child: Container(
decoration: BoxDecoration(color: textEditingController.text.length > 2 ? appTheme.blueA400 : appTheme.gray50, shape: BoxShape.circle),
height: 32,
width: 32,
child: Icon(Icons.send_rounded, size: 18, color: textEditingController.text.length > 2 ? appTheme.whiteA700 : appTheme.black),
),
),
],
),
],
),
),
),
appBar: AppBar(
centerTitle: true,
title: Text(
@@ -148,15 +54,7 @@ class _DietPreviewScreenState extends State<DietPreviewScreen> {
softWrap: true,
style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black),
),
actions: [
CustomPopup(
isAuthor: widget.diet.author.authorIsMe,
path: 'diet/${widget.diet.slug}',
complainedContentType: ComplainedContent.diet,
userSlug: widget.diet.author.authorSlug,
),
const SizedBox(width: 16),
],
actions: const [],
),
body: SingleChildScrollView(
controller: scrollController,

View File

@@ -33,7 +33,7 @@ class _RecipeCreateScreenState extends State<RecipeCreateScreen> {
late final CreateRecipeCubit createRecipeCubit;
final GlobalKey<MarkdownEditorState> _mdKey = GlobalKey<MarkdownEditorState>();
bool _mdToolbarVisible = false;
bool _isRecipeBeingPublished = false;
@override
void initState() {
super.initState();
@@ -58,9 +58,7 @@ class _RecipeCreateScreenState extends State<RecipeCreateScreen> {
return Scaffold(
backgroundColor: const Color(0xFFF0F2F5),
floatingActionButtonLocation: FloatingActionButtonLocation.centerFloat,
floatingActionButton: _mdToolbarVisible && _mdKey.currentState != null
? _mdKey.currentState!.buildToolbar(context)
: null,
floatingActionButton: _mdToolbarVisible && _mdKey.currentState != null ? _mdKey.currentState!.buildToolbar(context) : null,
appBar: AppBar(
title: widget.recipe != null ? const Text('Редактирование рецепта') : const Text('Добавление рецепта'),
centerTitle: true,
@@ -83,44 +81,55 @@ class _RecipeCreateScreenState extends State<RecipeCreateScreen> {
builder: (context, state) {
return Builder(
builder: (context) {
return InkWell(
key: AppKeys.recipeCreateScreenPublishButton,
onTap: () async {
if (context.read<CreateRecipeCubit>().validateAll()) {
final isSuccess = await context.read<CreateRecipeCubit>().publishRecipe(
widget.recipe == null ? false : true,
widget.recipe?.slug,
return _isRecipeBeingPublished
? const Center(child: Loader())
: InkWell(
key: AppKeys.recipeCreateScreenPublishButton,
onTap: () async {
setState(() {
_isRecipeBeingPublished = true;
});
if (context.read<CreateRecipeCubit>().validateAll()) {
final isSuccess = await context.read<CreateRecipeCubit>().publishRecipe(
widget.recipe == null ? false : true,
widget.recipe?.slug,
);
setState(() {
_isRecipeBeingPublished = false;
});
if (isSuccess) {
Future.delayed(const Duration(milliseconds: 500), () {
if (!context.mounted) return;
context.pop();
showBottomSheetWithBlur(
userRootNavigator: true,
context: context,
child: SuccessPublishedRecipeBottomSheet(title: state.entity.title ?? 'Рецепт'),
);
});
} else {
if (!context.mounted) return;
showErrorDialog(context, title: '${s.could_not_publish_recipe} ${state.entity.title}');
}
}
setState(() {
_isRecipeBeingPublished = false;
});
},
borderRadius: BorderRadius.circular(20),
child: Ink(
width: MediaQuery.of(context).size.width - 32,
decoration: BoxDecoration(color: appTheme.blueA400, borderRadius: BorderRadius.circular(20)),
child: Container(
padding: const EdgeInsets.all(16),
alignment: Alignment.center,
child: Text(
widget.recipe != null ? 'Обновить' : 'Опубликовать',
style: const TextStyle(fontSize: 16, color: Colors.white, fontWeight: FontWeight.w600),
),
),
),
);
if (isSuccess) {
Future.delayed(const Duration(milliseconds: 500), () {
if (!context.mounted) return;
context.pop();
showBottomSheetWithBlur(
userRootNavigator: true,
context: context,
child: SuccessPublishedRecipeBottomSheet(title: state.entity.title ?? 'Рецепт'),
);
});
} else {
if (!context.mounted) return;
showErrorDialog(context, title: '${s.could_not_publish_recipe} ${state.entity.title}');
}
}
},
borderRadius: BorderRadius.circular(20),
child: Ink(
width: MediaQuery.of(context).size.width - 32,
decoration: BoxDecoration(color: appTheme.blueA400, borderRadius: BorderRadius.circular(20)),
child: Container(
padding: const EdgeInsets.all(16),
alignment: Alignment.center,
child: Text(
widget.recipe != null ? 'Обновить' : 'Опубликовать',
style: const TextStyle(fontSize: 16, color: Colors.white, fontWeight: FontWeight.w600),
),
),
),
);
},
);
},

View File

@@ -44,16 +44,24 @@ class UserDiaryScreen extends StatelessWidget {
}
}
class DiaryScreenContent extends StatelessWidget {
class DiaryScreenContent extends StatefulWidget {
const DiaryScreenContent({super.key, this.diaryId, this.currentPlanDay});
final int? diaryId;
final int? currentPlanDay;
@override
State<DiaryScreenContent> createState() => _DiaryScreenContentState();
}
class _DiaryScreenContentState extends State<DiaryScreenContent> {
bool _isDiaryFinishing = false;
@override
Widget build(BuildContext context) {
final s = S.of(context);
return MultiBlocProvider(
providers: [
BlocProvider<DiaryCubit>(create: (BuildContext context) => sl<DiaryCubit>()..fetchDiaryV2(needLoading: true, diaryId: diaryId)),
BlocProvider<DiaryCubit>(create: (BuildContext context) => sl<DiaryCubit>()..fetchDiaryV2(needLoading: true, diaryId: widget.diaryId)),
BlocProvider(create: (context) => sl<ConsultantCommentsCubit>()),
],
child: BlocBuilder<DiaryCubit, DiaryState>(
@@ -63,13 +71,13 @@ class DiaryScreenContent extends StatelessWidget {
}
if (state is DiaryStateError) {
return Scaffold(
body: DataErrorWidget(onReload: () => context.read<DiaryCubit>().fetchDiaryV2(needLoading: false, diaryId: diaryId)),
body: DataErrorWidget(onReload: () => context.read<DiaryCubit>().fetchDiaryV2(needLoading: false, diaryId: widget.diaryId)),
);
}
return Scaffold(
backgroundColor: Colors.grey.shade200,
appBar: diaryId == null
appBar: widget.diaryId == null
? AppBar(toolbarHeight: 0)
: AppBar(
title: Text(
@@ -83,7 +91,7 @@ class DiaryScreenContent extends StatelessWidget {
physics: const BouncingScrollPhysics(),
child: Column(
children: [
if (diaryId == null) const CalendarWidget(),
if (widget.diaryId == null) const CalendarWidget(),
if (state is DiaryStateLoading) ...[const SizedBox(height: 60), const Loader()],
if (state is DiaryStateLoaded)
WakeupAndRetireWidget(
@@ -116,7 +124,23 @@ class DiaryScreenContent extends StatelessWidget {
if (state is DiaryStateLoaded && !state.isConsultantView && !state.isDiaryFinished && !state.isDiaryCommented)
Padding(
padding: const EdgeInsets.only(top: 12),
child: FinishDayButtonWidget(diaryDay: state.diary),
child: _isDiaryFinishing
? const Center(child: Loader())
: FinishDayButtonWidget(
onPressed: () async {
setState(() {
_isDiaryFinishing = true;
});
await context.read<DiaryCubit>().finishDiaryDay(
diaryId: state.diary.id,
wakeUp: state.diary.wakeup.toString(),
retire: state.diary.retire.toString(),
);
setState(() {
_isDiaryFinishing = false;
});
},
),
),
if (state is DiaryStateLoaded && state.isConsultantView && !state.isDiaryCommented) ...[
const SizedBox(height: 16),

View File

@@ -1,13 +1,10 @@
import 'package:eatvio_app/features/diary/entities/diary_day_entity.dart';
import 'package:eatvio_app/features/diary/presentation/bloc/diary_cubit.dart';
import 'package:eatvio_app/core/theme/theme_helper.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
class FinishDayButtonWidget extends StatefulWidget {
final DiaryDayEntity diaryDay;
final Future<void> Function() onPressed;
const FinishDayButtonWidget({super.key, required this.diaryDay});
const FinishDayButtonWidget({super.key, required this.onPressed});
@override
State<FinishDayButtonWidget> createState() => _FinishDayButtonWidgetState();
@@ -17,11 +14,7 @@ class _FinishDayButtonWidgetState extends State<FinishDayButtonWidget> {
@override
Widget build(BuildContext context) {
return InkWell(
onTap: () => context.read<DiaryCubit>().finishDiaryDay(
diaryId: widget.diaryDay.id,
wakeUp: widget.diaryDay.wakeup.toString(),
retire: widget.diaryDay.retire.toString(),
),
onTap: widget.onPressed,
borderRadius: BorderRadius.circular(20),
child: Ink(
padding: const EdgeInsets.symmetric(horizontal: 8),

View File

@@ -0,0 +1,44 @@
import 'package:freezed_annotation/freezed_annotation.dart';
part 'oauth_account_model.freezed.dart';
part 'oauth_account_model.g.dart';
@freezed
abstract class OAuthAccountModel with _$OAuthAccountModel {
const factory OAuthAccountModel({
@JsonKey(name: 'oauth_id') required String oauthId,
required String name,
required String email,
required String phone,
@JsonKey(name: 'linked_at') required DateTime linkedAt,
}) = _OAuthAccountModel;
factory OAuthAccountModel.fromJson(Map<String, dynamic> json) => _$OAuthAccountModelFromJson(json);
}
@freezed
abstract class OAuthAccountsResponseModel with _$OAuthAccountsResponseModel {
const factory OAuthAccountsResponseModel({required bool success, required OAuthAccountsDataModel data}) = _OAuthAccountsResponseModel;
factory OAuthAccountsResponseModel.fromJson(Map<String, dynamic> json) => _$OAuthAccountsResponseModelFromJson(json);
}
@freezed
abstract class OAuthAccountsDataModel with _$OAuthAccountsDataModel {
const factory OAuthAccountsDataModel({
@JsonKey(fromJson: _oauthAccountFromJson) OAuthAccountModel? vk,
@JsonKey(fromJson: _oauthAccountFromJson) OAuthAccountModel? yandex,
}) = _OAuthAccountsDataModel;
factory OAuthAccountsDataModel.fromJson(Map<String, dynamic> json) => _$OAuthAccountsDataModelFromJson(json);
}
// Custom converter to handle both empty array [] and object
OAuthAccountModel? _oauthAccountFromJson(dynamic json) {
if (json == null) return null;
if (json is List && json.isEmpty) return null;
if (json is Map<String, dynamic>) {
return OAuthAccountModel.fromJson(json);
}
return null;
}

View File

@@ -0,0 +1,17 @@
import 'package:freezed_annotation/freezed_annotation.dart';
part 'oauth_attach_request.freezed.dart';
part 'oauth_attach_request.g.dart';
@freezed
abstract class OAuthAttachRequest with _$OAuthAttachRequest {
const factory OAuthAttachRequest({
required String provider,
@JsonKey(name: 'client_id') String? clientId,
@JsonKey(name: 'access_token') required String accessToken,
@JsonKey(name: 'refresh_token') required String refreshToken,
}) = _OAuthAttachRequest;
factory OAuthAttachRequest.fromJson(Map<String, dynamic> json) => _$OAuthAttachRequestFromJson(json);
}

View File

@@ -4,6 +4,8 @@ import 'package:eatvio_app/features/consultations/repositories/consultations_rep
import 'package:eatvio_app/features/plans/enums/chosen_plan_state_enum.dart';
import 'package:eatvio_app/features/profile/models/profile_model.dart';
import 'package:eatvio_app/features/profile/repositories/profile_repository.dart';
import 'package:eatvio_app/features/settings/models/oauth_account_model.dart';
import 'package:eatvio_app/features/settings/models/oauth_attach_request.dart';
import 'package:eatvio_app/features/settings/models/profile_settings_model.dart';
import 'package:eatvio_app/features/settings/presentation/bloc/settings_cubit/settings_state.dart';
import 'package:eatvio_app/features/settings/repositories/service_repository.dart';
@@ -26,6 +28,7 @@ class SettingsCubit extends Cubit<SettingsState> {
final profile = await profileRepository.getMyProfile();
late final ConsultationRequestModel? consultantInfo;
late final bool hasActiveConsultation;
late final OAuthAccountsDataModel? oauthAccounts;
try {
if (profile.phoneConfirmationState.contains('confirmed')) {
@@ -43,6 +46,13 @@ class SettingsCubit extends Cubit<SettingsState> {
hasActiveConsultation = false;
}
try {
final oauthResponse = await settingsRepository.getOAuthAccounts();
oauthAccounts = oauthResponse.data;
} catch (e) {
oauthAccounts = null;
}
try {
emit(
SettingsState.loaded(
@@ -51,6 +61,7 @@ class SettingsCubit extends Cubit<SettingsState> {
profile: profile,
consultationRequestModel: consultantInfo,
hasActiveConsultation: hasActiveConsultation,
oauthAccounts: oauthAccounts,
),
);
} catch (e) {
@@ -87,6 +98,35 @@ class SettingsCubit extends Cubit<SettingsState> {
}
}
Future<void> linkOAuthAccount({
required String provider,
required String accessToken,
required String refreshToken,
String? clientId,
}) async {
try {
final request = OAuthAttachRequest(
provider: provider,
clientId: clientId,
accessToken: accessToken,
refreshToken: refreshToken,
);
await settingsRepository.linkOAuthAccount(request: request);
await init();
} catch (e) {
rethrow;
}
}
Future<void> unlinkOAuthAccount({required String provider}) async {
try {
await settingsRepository.unlinkOAuthAccount(provider: provider);
await init();
} catch (e) {
rethrow;
}
}
Future<bool> _checkActiveConsultation() async {
try {
final userPurchases = await consultationsRepository.getUserPurchases(null, null);

View File

@@ -1,5 +1,6 @@
import 'package:eatvio_app/features/consultant/become_consultant/models/consultation_request_model.dart';
import 'package:eatvio_app/features/profile/models/profile_model.dart';
import 'package:eatvio_app/features/settings/models/oauth_account_model.dart';
import 'package:eatvio_app/features/settings/models/profile_settings_model.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
@@ -17,6 +18,7 @@ abstract class SettingsState with _$SettingsState {
required ProfileModel profile,
ConsultationRequestModel? consultationRequestModel,
required bool hasActiveConsultation,
OAuthAccountsDataModel? oauthAccounts,
}) = SettingsStateLoaded;
const factory SettingsState.error({

View File

@@ -6,6 +6,7 @@ import 'package:eatvio_app/core/widgets/custom_outlined_button.dart';
import 'package:eatvio_app/core/widgets/data_error_widget.dart';
import 'package:eatvio_app/core/widgets/loader.dart';
import 'package:eatvio_app/core/widgets/show_error_dialog.dart';
import 'package:eatvio_app/features/auth/sdk/social_auth_facade.dart';
import 'package:eatvio_app/features/consultant/become_consultant/models/consultation_request_model.dart';
import 'package:eatvio_app/features/settings/entities/settings_entity.dart';
import 'package:eatvio_app/features/settings/presentation/bloc/settings_cubit/settings_cubit.dart';
@@ -16,6 +17,7 @@ import 'package:eatvio_app/generated/l10n.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:go_router/go_router.dart';
class SettingsScreen extends StatefulWidget {
@@ -39,12 +41,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
return AlertDialog(
title: const Text('Предупреждение'),
content: const Text('Во время консультации этот параметр нельзя отключить'),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Понятно'),
),
],
actions: [TextButton(onPressed: () => Navigator.of(context).pop(), child: const Text('Понятно'))],
);
},
);
@@ -74,6 +71,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
physics: const BouncingScrollPhysics(),
child: Column(
children: [
_oauthAccounts(state, settingsCubit),
const SizedBox(height: 12),
_additionalInfo(state),
const SizedBox(height: 12),
_notifications(state),
@@ -328,6 +327,326 @@ class _SettingsScreenState extends State<SettingsScreen> {
),
);
}
Widget _oauthAccounts(SettingsStateLoaded state, SettingsCubit settingsCubit) {
final isVkLinked = state.oauthAccounts?.vk != null;
final isYandexLinked = state.oauthAccounts?.yandex != null;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Padding(
padding: EdgeInsets.symmetric(horizontal: 16),
child: Text(
'Привязанные аккаунты',
style: TextStyle(fontSize: 16, height: 1, color: Colors.grey, fontFamily: 'Mulish'),
),
),
const SizedBox(height: 12),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
_OAuthAccountButton(
onTap: () => _onVkPressed(state, settingsCubit),
backgroundColor: const Color(0xFF2787F5),
tooltip: 'VK ID',
icon: SvgPicture.asset('assets/icons/vk_logo.svg', height: 24, width: 24),
isLinked: isVkLinked,
accountName: state.oauthAccounts?.vk?.name,
),
const SizedBox(width: 12),
_OAuthAccountButton(
onTap: () => _onOkPressed(state, settingsCubit),
backgroundColor: const Color(0xFFF58220),
tooltip: 'Одноклассники',
icon: SvgPicture.asset('assets/icons/ok_logo.svg', height: 24, width: 24),
isLinked: isVkLinked,
accountName: state.oauthAccounts?.vk?.name,
),
const SizedBox(width: 12),
_OAuthAccountButton(
onTap: () => _onMailRuPressed(state, settingsCubit),
backgroundColor: const Color(0xFF005FF9),
tooltip: 'Mail.ru',
icon: SvgPicture.asset('assets/icons/mail_logo.svg', height: 24, width: 24),
isLinked: isVkLinked,
accountName: state.oauthAccounts?.vk?.name,
),
const SizedBox(width: 12),
_OAuthAccountButton(
onTap: () => _onYandexPressed(state, settingsCubit),
backgroundColor: const Color(0xFFFFCC00),
tooltip: 'Яндекс ID',
icon: SvgPicture.asset('assets/icons/yandex_logo.svg', height: 24, width: 24),
isLinked: isYandexLinked,
accountName: state.oauthAccounts?.yandex?.name,
),
],
),
),
const SizedBox(height: 12),
],
);
}
Future<void> _onVkPressed(SettingsStateLoaded state, SettingsCubit settingsCubit) async {
if (state.oauthAccounts?.vk != null) {
// Отвязываем аккаунт
if (!mounted) return;
final shouldUnlink = await _showUnlinkConfirmationDialog(context, 'VK ID');
if (shouldUnlink == true) {
try {
await settingsCubit.unlinkOAuthAccount(provider: 'vk');
} catch (e) {
if (mounted) {
showErrorDialog(context, description: 'Не удалось отвязать аккаунт');
}
}
}
return;
}
try {
if (!mounted) return;
final authResult = await SocialAuthFacade.signInWithVkAndFetchProfile(context: context);
if (authResult == null) return;
try {
await settingsCubit.linkOAuthAccount(
provider: 'vk',
accessToken: authResult.accessToken,
refreshToken: authResult.refreshToken ?? '',
clientId: authResult.clientId,
);
} catch (e) {
if (mounted) {
showErrorDialog(context, description: 'Не удалось привязать аккаунт');
}
}
} catch (e) {
if (mounted) {
showErrorDialog(context, description: 'Не удалось привязать аккаунт');
}
}
}
Future<void> _onOkPressed(SettingsStateLoaded state, SettingsCubit settingsCubit) async {
if (state.oauthAccounts?.vk != null) {
// Отвязываем аккаунт (OK использует тот же VK ID провайдер)
if (!mounted) return;
final shouldUnlink = await _showUnlinkConfirmationDialog(context, 'Одноклассники');
if (shouldUnlink == true) {
try {
await settingsCubit.unlinkOAuthAccount(provider: 'vk');
} catch (e) {
if (mounted) {
showErrorDialog(context, description: 'Не удалось отвязать аккаунт');
}
}
}
return;
}
try {
if (!mounted) return;
final authResult = await SocialAuthFacade.signInWithOkAndFetchProfile();
if (authResult == null) return;
try {
await settingsCubit.linkOAuthAccount(
provider: 'vk',
accessToken: authResult.accessToken,
refreshToken: authResult.refreshToken ?? '',
clientId: authResult.clientId,
);
} catch (e) {
if (mounted) {
showErrorDialog(context, description: 'Не удалось привязать аккаунт');
}
}
} catch (e) {
if (mounted) {
showErrorDialog(context, description: 'Не удалось привязать аккаунт');
}
}
}
Future<void> _onMailRuPressed(SettingsStateLoaded state, SettingsCubit settingsCubit) async {
if (state.oauthAccounts?.vk != null) {
// Отвязываем аккаунт (Mail.ru использует тот же VK ID провайдер)
if (!mounted) return;
final shouldUnlink = await _showUnlinkConfirmationDialog(context, 'Mail.ru');
if (shouldUnlink == true) {
try {
await settingsCubit.unlinkOAuthAccount(provider: 'vk');
} catch (e) {
if (mounted) {
showErrorDialog(context, description: 'Не удалось отвязать аккаунт');
}
}
}
return;
}
try {
if (!mounted) return;
final authResult = await SocialAuthFacade.signInWithMailRuAndFetchProfile();
if (authResult == null) return;
try {
await settingsCubit.linkOAuthAccount(
provider: 'vk',
accessToken: authResult.accessToken,
refreshToken: authResult.refreshToken ?? '',
clientId: authResult.clientId,
);
} catch (e) {
if (mounted) {
showErrorDialog(context, description: 'Не удалось привязать аккаунт');
}
}
} catch (e) {
if (mounted) {
showErrorDialog(context, description: 'Не удалось привязать аккаунт');
}
}
}
Future<void> _onYandexPressed(SettingsStateLoaded state, SettingsCubit settingsCubit) async {
if (state.oauthAccounts?.yandex != null) {
// Отвязываем аккаунт
if (!mounted) return;
final shouldUnlink = await _showUnlinkConfirmationDialog(context, 'Яндекс ID');
if (shouldUnlink == true) {
try {
await settingsCubit.unlinkOAuthAccount(provider: 'yandex');
} catch (e) {
if (mounted) {
showErrorDialog(context, description: 'Не удалось отвязать аккаунт');
}
}
}
return;
}
try {
if (!mounted) return;
final authResult = await SocialAuthFacade.signInWithYandex();
if (authResult == null) {
if (mounted) {
showErrorDialog(context, description: 'Не удалось авторизоваться через Яндекс');
}
return;
}
// Проверяем, что refreshToken не пустой для Яндекс
if (authResult.refreshToken == null || authResult.refreshToken!.isEmpty) {
if (mounted) {
showErrorDialog(context, description: 'Не удалось получить токен обновления от Яндекс');
}
return;
}
// Проверяем, что clientId не пустой для Яндекс
if (authResult.clientId.isEmpty) {
if (mounted) {
showErrorDialog(context, description: 'Не удалось получить client ID от Яндекс');
}
return;
}
try {
await settingsCubit.linkOAuthAccount(
provider: 'yandex',
accessToken: authResult.accessToken,
refreshToken: authResult.refreshToken!,
clientId: authResult.clientId,
);
} catch (e) {
if (mounted) {
showErrorDialog(context, description: 'Не удалось привязать аккаунт: ${e.toString()}');
}
}
} catch (e) {
if (mounted) {
showErrorDialog(context, description: 'Не удалось привязать аккаунт: ${e.toString()}');
}
}
}
Future<bool?> _showUnlinkConfirmationDialog(BuildContext context, String providerName) {
return showDialog<bool>(
context: context,
builder: (context) => CupertinoAlertDialog(
title: const Text('Отвязать аккаунт'),
content: Text('Вы точно хотите отвязать аккаунт $providerName?'),
actions: [
CupertinoDialogAction(
onPressed: () {
Navigator.of(context).pop(false);
},
child: const Text('Отмена'),
),
CupertinoDialogAction(
onPressed: () {
Navigator.of(context).pop(true);
},
isDestructiveAction: true,
child: const Text('Отвязать'),
),
],
),
);
}
}
class _OAuthAccountButton extends StatelessWidget {
const _OAuthAccountButton({
required this.onTap,
required this.icon,
required this.backgroundColor,
required this.tooltip,
required this.isLinked,
this.accountName,
});
final VoidCallback onTap;
final Widget icon;
final Color backgroundColor;
final String tooltip;
final bool isLinked;
final String? accountName;
@override
Widget build(BuildContext context) {
return Expanded(
child: Opacity(
opacity: isLinked ? 0.5 : 1.0,
child: Tooltip(
message: tooltip,
child: InkWell(
borderRadius: BorderRadius.circular(20),
onTap: onTap,
child: Ink(
height: 48,
decoration: BoxDecoration(
color: Colors.transparent,
borderRadius: BorderRadius.circular(20),
border: Border.all(color: const Color(0xFFDBDBDB), width: 1),
),
child: Center(child: icon),
),
),
),
),
);
}
}
class DeleteAccountBottomSheet extends StatefulWidget {

View File

@@ -1,4 +1,6 @@
import 'package:eatvio_app/features/profile/models/profile_model.dart';
import 'package:eatvio_app/features/settings/models/oauth_account_model.dart';
import 'package:eatvio_app/features/settings/models/oauth_attach_request.dart';
import 'package:eatvio_app/features/settings/models/profile_settings_model.dart';
import 'package:eatvio_app/features/settings/services/settings_service.dart';
@@ -15,25 +17,41 @@ class SettingsRepository {
}
}
Future<ProfileSettingsModel> updateProfileSettings({
required ProfileSettingsModel profileSettingsModel,
}) async {
Future<ProfileSettingsModel> updateProfileSettings({required ProfileSettingsModel profileSettingsModel}) async {
try {
return await settingsService.updateProfileSettings(
data: profileSettingsModel.toJson(),
);
return await settingsService.updateProfileSettings(data: profileSettingsModel.toJson());
} catch (e) {
rethrow;
}
}
Future<ProfileDetailsModel> updateProfileDetails({
required ProfileDetailsModel profileDetailsModel,
}) async {
Future<ProfileDetailsModel> updateProfileDetails({required ProfileDetailsModel profileDetailsModel}) async {
try {
return await settingsService.updateProfileDetails(
data: profileDetailsModel.toJson(),
);
return await settingsService.updateProfileDetails(data: profileDetailsModel.toJson());
} catch (e) {
rethrow;
}
}
Future<OAuthAccountsResponseModel> getOAuthAccounts() async {
try {
return await settingsService.getOAuthAccounts();
} catch (e) {
rethrow;
}
}
Future<void> linkOAuthAccount({required OAuthAttachRequest request}) async {
try {
return await settingsService.linkOAuthAccount(request);
} catch (e) {
rethrow;
}
}
Future<void> unlinkOAuthAccount({required String provider}) async {
try {
return await settingsService.unlinkOAuthAccount(provider);
} catch (e) {
rethrow;
}

View File

@@ -1,5 +1,7 @@
import 'package:dio/dio.dart';
import 'package:eatvio_app/features/profile/models/profile_model.dart';
import 'package:eatvio_app/features/settings/models/oauth_account_model.dart';
import 'package:eatvio_app/features/settings/models/oauth_attach_request.dart';
import 'package:eatvio_app/features/settings/models/profile_settings_model.dart';
import 'package:retrofit/retrofit.dart';
@@ -13,12 +15,17 @@ abstract class SettingsService {
Future<ProfileSettingsModel> getProfileSettings();
@PUT('/api/profile/settings/update')
Future<ProfileSettingsModel> updateProfileSettings({
@Body() required Map<String, dynamic> data,
});
Future<ProfileSettingsModel> updateProfileSettings({@Body() required Map<String, dynamic> data});
@PUT('/api/profile/update')
Future<ProfileDetailsModel> updateProfileDetails({
@Body() required Map<String, dynamic> data,
});
Future<ProfileDetailsModel> updateProfileDetails({@Body() required Map<String, dynamic> data});
@GET('/api/oauth/accounts')
Future<OAuthAccountsResponseModel> getOAuthAccounts();
@POST('/api/oauth/attach')
Future<void> linkOAuthAccount(@Body() OAuthAttachRequest request);
@DELETE('/api/oauth/accounts/{provider}')
Future<void> unlinkOAuthAccount(@Path('provider') String provider);
}

View File

@@ -1,6 +1,6 @@
name: eatvio_app
description: A new Flutter project.
version: 1.3.3+82
version: 1.3.4+83
publish_to: 'none'
environment:
@@ -25,10 +25,10 @@ dependencies:
flutter_form_builder: ^10.2.0
image_picker: ^1.2.0
flutter_typeahead: ^5.2.0
go_router: ^16.1.0
go_router: ^17.0.0
keyboard_dismisser: ^3.0.0
retrofit: ^4.7.1
get_it: ^8.2.0
get_it: ^9.0.5
dio: ^5.9.0
dart_pusher_channels: ^1.2.3
photo_view: ^0.15.0
@@ -37,11 +37,11 @@ dependencies:
easy_debounce: ^2.0.3
badges: ^3.1.2
google_fonts: ^6.3.0
syncfusion_flutter_charts: ^30.2.5
syncfusion_flutter_gauges: ^30.2.5
syncfusion_flutter_charts: ^31.2.10
syncfusion_flutter_gauges: ^31.2.10
table_calendar: ^3.2.0
flutter_keyboard_visibility: ^6.0.0
share_plus: ^11.1.0
share_plus: ^12.0.1
flutter_html: ^3.0.0
file_picker: ^10.3.1
audioplayers: ^6.5.0
@@ -49,7 +49,7 @@ dependencies:
mask_text_input_formatter: ^2.9.0
lottie: ^3.3.1
flutter_portal: ^1.1.4
fluttertoast: ^8.2.12
fluttertoast: ^9.0.0
flutter_rating_bar: ^4.0.1
flutter_image_compress: ^2.4.0
image: ^4.5.4
@@ -57,8 +57,8 @@ dependencies:
http: ^1.5.0
flutter_native_splash: ^2.4.6
webview_flutter: ^4.13.0
sentry_flutter: ^9.6.0
connectivity_plus: ^6.1.5
sentry_flutter: ^9.8.0
connectivity_plus: ^7.0.0
visibility_detector: ^0.4.0+2
firebase_core: ^4.0.0
firebase_messaging: ^16.0.0
@@ -67,17 +67,18 @@ dependencies:
collection: ^1.19.1
shared_preferences: ^2.5.3
version: ^3.0.2
package_info_plus: ^8.3.1
device_info_plus: ^11.5.0
package_info_plus: ^9.0.0
device_info_plus: ^12.2.0
permission_handler: ^12.0.1
qr_code_scanner_plus: ^2.0.10+1
flutter_dotenv: ^5.2.1
uuid: ^4.5.1
flutter_dotenv: ^6.0.0
uuid: ^4.5.2
appmetrica_plugin: ^3.3.0
vk_id: ^1.0.3
flutter_login_yandex: ^0.0.5
markdown: ^7.3.0
gpassword: ^1.0.0
mobile_scanner: ^7.1.3
dev_dependencies:
build_runner: ^2.7.0

View File

@@ -35,7 +35,7 @@ void main() {
setUp(() async {
await sl.reset();
// Ensure dotenv has required keys before AppConfig constructor reads them
dotenv.testLoad(fileInput: 'DEV_BASE_URL=http://localhost\nBASE_URL=http://localhost');
dotenv.loadFromString(envString: 'DEV_BASE_URL=http://localhost\nBASE_URL=http://localhost');
// Register minimal AppConfig for tests to satisfy dependencies (e.g. image URL building)
sl.registerLazySingleton<AppConfig>(() => AppConfig(buildType: AppBuildType.tests)..setBaseUrl('http://localhost'));
mockCreateDietRepository = MockCreateDietRepository();

View File

@@ -35,7 +35,7 @@ void main() {
setUp(() async {
await sl.reset();
dotenv.testLoad(fileInput: 'DEV_BASE_URL=\nBASE_URL=\n');
dotenv.loadFromString(envString: 'DEV_BASE_URL=\nBASE_URL=\n');
mockRecipeRepository = MockRecipeRepository();
mockGlossaryRepository = MockGlossaryRepository();
createRecipeCubit = CreateRecipeCubit(recipeRepository: mockRecipeRepository, glossaryRepository: mockGlossaryRepository);

View File

@@ -220,7 +220,7 @@ void main() {
testWidgets('should display FinishDayButtonWidget', (WidgetTester tester) async {
// Arrange
final widget = FinishDayButtonWidget(diaryDay: testDiaryEntity);
final widget = FinishDayButtonWidget(onPressed: () async {});
// Act
await tester.pumpWidget(createTestWidget(widget));
@@ -234,7 +234,7 @@ void main() {
// Arrange
diaryCubit.emit(DiaryState.loaded(diary: testDiaryEntity, nutrition: testNutrition, selectedDay: DateTime(2024, 1, 15), selectedMeal: 1));
final widget = FinishDayButtonWidget(diaryDay: testDiaryEntity);
final widget = FinishDayButtonWidget(onPressed: () async {});
await tester.pumpWidget(createTestWidget(widget));
await tester.pumpAndSettle();