Merge pull request 'feat(oauth): Issue-691+180' (#762) from mso7nueh/eatvio_app:main into main

Reviewed-on: eatvio/eatvio_app#762
This commit is contained in:
mso7nueh
2025-11-11 14:40:09 +00:00
8 changed files with 472 additions and 127 deletions

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

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