feat: enable removing user icon (#3487)

* feat: enable removing user icon

* fix: generate to true

* fix: review comments

* fix: more review comments

* fix: integration test and final changes
This commit is contained in:
Mathias Mogensen 2023-09-22 15:20:07 +02:00 committed by GitHub
parent 047f1a0b39
commit 1b966171c4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
30 changed files with 392 additions and 178 deletions

View File

@ -0,0 +1,54 @@
import 'package:appflowy/workspace/application/settings/prelude.dart';
import 'package:appflowy/workspace/presentation/settings/widgets/settings_user_view.dart';
import 'package:appflowy/workspace/presentation/widgets/user_avatar.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import '../util/util.dart';
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
group('Settings: user icon tests', () {
testWidgets('select icon, select default option', (tester) async {
await tester.initializeAppFlowy();
await tester.tapGoButton();
tester.expectToSeeHomePage();
await tester.openSettings();
await tester.openSettingsPage(SettingsPage.user);
final userAvatarFinder = find.descendant(
of: find.byType(SettingsUserView),
matching: find.byType(UserAvatar),
);
// Open icon picker dialog
await tester.tap(userAvatarFinder);
await tester.pumpAndSettle();
// Select first option that isn't default
await tester.tap(find.byType(IconOption).first);
await tester.pumpAndSettle();
UserAvatar userAvatar = tester.widget(userAvatarFinder) as UserAvatar;
expect(userAvatar.iconUrl, isNotEmpty);
// Open icon picker dialog again
await tester.tap(userAvatarFinder);
await tester.pumpAndSettle();
// Tap the default option
await tester.tap(
find.descendant(
of: find.byType(IconGallery),
matching: find.byType(UserAvatar),
),
);
await tester.pumpAndSettle();
userAvatar = tester.widget(userAvatarFinder) as UserAvatar;
expect(userAvatar.iconUrl, isEmpty);
});
});
}

View File

@ -43,6 +43,15 @@ class SettingsUserViewBloc extends Bloc<SettingsUserEvent, SettingsUserState> {
);
});
},
removeUserIcon: () {
// Empty Icon URL = No icon
_userService.updateUserProfile(iconUrl: "").then((result) {
result.fold(
(l) => null,
(err) => Log.error(err),
);
});
},
updateUserOpenAIKey: (openAIKey) {
_userService.updateUserProfile(openAIKey: openAIKey).then((result) {
result.fold(
@ -105,8 +114,9 @@ class SettingsUserEvent with _$SettingsUserEvent {
const factory SettingsUserEvent.initial() = _Initial;
const factory SettingsUserEvent.updateUserName(String name) = _UpdateUserName;
const factory SettingsUserEvent.updateUserEmail(String email) = _UpdateEmail;
const factory SettingsUserEvent.updateUserIcon(String iconUrl) =
const factory SettingsUserEvent.updateUserIcon({required String iconUrl}) =
_UpdateUserIcon;
const factory SettingsUserEvent.removeUserIcon() = _RemoveUserIcon;
const factory SettingsUserEvent.updateUserOpenAIKey(String openAIKey) =
_UpdateUserOpenaiKey;
const factory SettingsUserEvent.didReceiveUserProfile(

View File

@ -1,11 +1,9 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/plugins/document/presentation/more/cubit/document_appearance_cubit.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/util/color_generator/color_generator.dart';
import 'package:appflowy/workspace/application/menu/menu_user_bloc.dart';
import 'package:appflowy/workspace/presentation/settings/settings_dialog.dart';
import 'package:appflowy/workspace/presentation/settings/widgets/settings_user_view.dart';
import 'package:flowy_infra/size.dart';
import 'package:appflowy/workspace/presentation/widgets/user_avatar.dart';
import 'package:flowy_infra_ui/style_widget/text.dart';
import 'package:flowy_infra_ui/widget/spacing.dart';
import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'
@ -34,7 +32,10 @@ class SidebarUser extends StatelessWidget {
builder: (context, state) => Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
_buildAvatar(context, state),
UserAvatar(
iconUrl: state.userProfile.iconUrl,
name: state.userProfile.name,
),
const HSpace(10),
Expanded(
child: _buildUserName(context, state),
@ -46,50 +47,6 @@ class SidebarUser extends StatelessWidget {
);
}
Widget _buildAvatar(BuildContext context, MenuUserState state) {
String iconUrl = state.userProfile.iconUrl;
if (iconUrl.isEmpty) {
iconUrl = defaultUserAvatar;
final String name = _userName(state.userProfile);
final Color color = ColorGenerator().generateColorFromString(name);
const initialsCount = 2;
// Taking the first letters of the name components and limiting to 2 elements
final nameInitials = name
.split(' ')
.where((element) => element.isNotEmpty)
.take(initialsCount)
.map((element) => element[0].toUpperCase())
.join('');
return Container(
width: 28,
height: 28,
alignment: Alignment.center,
decoration: BoxDecoration(
color: color,
shape: BoxShape.circle,
),
child: FlowyText.semibold(
nameInitials,
color: Colors.white,
fontSize: nameInitials.length == initialsCount ? 12 : 14,
),
);
}
return SizedBox.square(
dimension: 25,
child: ClipRRect(
borderRadius: Corners.s5Border,
child: CircleAvatar(
backgroundColor: Colors.transparent,
child: FlowySvg(
FlowySvgData('emoji/$iconUrl'),
blendMode: null,
),
),
),
);
}
Widget _buildUserName(BuildContext context, MenuUserState state) {
final String name = _userName(state.userProfile);
return FlowyText.medium(

View File

@ -9,10 +9,14 @@ import 'package:appflowy/user/application/auth/auth_service.dart';
import 'package:appflowy/util/debounce.dart';
import 'package:appflowy/workspace/application/user/settings_user_bloc.dart';
import 'package:appflowy/workspace/presentation/widgets/dialogs.dart';
import 'package:appflowy/workspace/presentation/widgets/flowy_tooltip.dart';
import 'package:appflowy/workspace/presentation/widgets/user_avatar.dart';
import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart';
import 'package:collection/collection.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra/size.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flowy_infra_ui/style_widget/hover.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
@ -49,21 +53,16 @@ class SettingsUserView extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
_renderUserNameInput(context),
_buildUserIconSetting(context),
if (isSupabaseEnabled) ...[
const VSpace(20),
const VSpace(12),
UserEmailInput(user.email)
],
const VSpace(20),
_renderCurrentIcon(context),
const VSpace(20),
const VSpace(12),
_renderCurrentOpenaiKey(context),
const VSpace(20),
// _renderHistoricalUser(context),
const VSpace(12),
_renderLoginOrLogoutButton(context, state),
const VSpace(20),
const VSpace(12),
],
),
),
@ -71,6 +70,115 @@ class SettingsUserView extends StatelessWidget {
);
}
Row _buildUserIconSetting(BuildContext context) {
return Row(
children: [
GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => _showIconPickerDialog(context),
child: FlowyHover(
style: const HoverStyle.transparent(),
builder: (context, onHover) {
Widget avatar = UserAvatar(
iconUrl: user.iconUrl,
name: user.name,
isLarge: true,
);
if (onHover) {
avatar = _avatarOverlay(
context: context,
hasIcon: user.iconUrl.isNotEmpty,
child: avatar,
);
}
return avatar;
},
),
),
const HSpace(12),
Flexible(child: _renderUserNameInput(context)),
],
);
}
Future<void> _showIconPickerDialog(BuildContext context) {
return showDialog(
context: context,
builder: (dialogContext) => SimpleDialog(
title: FlowyText.medium(
LocaleKeys.settings_user_selectAnIcon.tr(),
fontSize: FontSizes.s16,
),
children: [
SizedBox(
height: 300,
width: 300,
child: IconGallery(
defaultOption: _defaultIconOption(context),
selectedIcon: user.iconUrl,
onSelectIcon: (iconUrl, isSelected) {
if (isSelected) {
return Navigator.of(context).pop();
}
context
.read<SettingsUserViewBloc>()
.add(SettingsUserEvent.updateUserIcon(iconUrl: iconUrl));
Navigator.of(context).pop();
},
),
),
],
),
);
}
// Returns a Widget that is the Default Option for the
// Icon Gallery, enabling users to choose the auto-generated
// icon again.
Widget _defaultIconOption(BuildContext context) {
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () {
context
.read<SettingsUserViewBloc>()
.add(const SettingsUserEvent.removeUserIcon());
Navigator.of(context).pop();
},
child: DecoratedBox(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(6),
color: user.iconUrl.isEmpty
? Theme.of(context).colorScheme.primary
: Colors.transparent,
),
child: FlowyHover(
style: HoverStyle(
hoverColor: user.iconUrl.isEmpty
? Colors.transparent
: Theme.of(context).colorScheme.tertiaryContainer,
),
child: Padding(
padding: const EdgeInsets.all(4.0),
child: DecoratedBox(
decoration: const BoxDecoration(
color: Colors.white,
shape: BoxShape.circle,
),
child: UserAvatar(
iconUrl: "",
name: user.name,
isLarge: true,
),
),
),
),
),
);
}
/// Renders either a login or logout button based on the user's authentication status, or nothing if Supabase is not enabled.
///
/// This function checks the current user's authentication type and Supabase
@ -86,9 +194,7 @@ class SettingsUserView extends StatelessWidget {
// If the user is logged in locally, render a third-party login button.
if (state.userProfile.authType == AuthTypePB.Local) {
return SettingThirdPartyLogin(
didLogin: didLogin,
);
return SettingThirdPartyLogin(didLogin: didLogin);
}
return SettingLogoutButton(user: user, didLogout: didLogout);
@ -100,20 +206,49 @@ class SettingsUserView extends StatelessWidget {
return UserNameInput(name);
}
Widget _renderCurrentIcon(BuildContext context) {
String iconUrl =
context.read<SettingsUserViewBloc>().state.userProfile.iconUrl;
if (iconUrl.isEmpty) {
iconUrl = defaultUserAvatar;
}
return _CurrentIcon(iconUrl);
}
Widget _renderCurrentOpenaiKey(BuildContext context) {
final String openAIKey =
context.read<SettingsUserViewBloc>().state.userProfile.openaiKey;
return _OpenaiKeyInput(openAIKey);
}
Widget _avatarOverlay({
required BuildContext context,
required bool hasIcon,
required Widget child,
}) =>
FlowyTooltip.delayedTooltip(
message: LocaleKeys.settings_user_tooltipSelectIcon.tr(),
child: Stack(
children: [
Container(
width: 56,
height: 56,
foregroundDecoration: BoxDecoration(
color: Theme.of(context)
.colorScheme
.primary
.withOpacity(hasIcon ? 0.8 : 0.5),
shape: BoxShape.circle,
),
child: child,
),
const Positioned(
top: 0,
left: 0,
bottom: 0,
right: 0,
child: Center(
child: SizedBox(
width: 32,
height: 32,
child: FlowySvg(FlowySvgs.emoji_s),
),
),
),
],
),
);
}
@visibleForTesting
@ -317,69 +452,19 @@ class _OpenaiKeyInputState extends State<_OpenaiKeyInput> {
}
}
class _CurrentIcon extends StatelessWidget {
final String iconUrl;
const _CurrentIcon(this.iconUrl, {Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
void setIcon(String iconUrl) {
context
.read<SettingsUserViewBloc>()
.add(SettingsUserEvent.updateUserIcon(iconUrl));
Navigator.of(context).pop();
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
LocaleKeys.settings_user_icon.tr(),
style: Theme.of(context).textTheme.titleSmall!.copyWith(
fontWeight: FontWeight.w500,
fontSize: 13,
),
),
InkWell(
borderRadius: Corners.s6Border,
hoverColor: Theme.of(context).colorScheme.secondaryContainer,
onTap: () {
showDialog(
context: context,
builder: (BuildContext context) {
return SimpleDialog(
title: FlowyText.medium(
LocaleKeys.settings_user_selectAnIcon.tr(),
fontSize: FontSizes.s16,
),
children: [
SizedBox(
height: 300,
width: 300,
child: IconGallery(setIcon),
)
],
);
},
);
},
child: Container(
margin: const EdgeInsets.fromLTRB(0, 5, 5, 5),
child: FlowySvg(
FlowySvgData('emoji/$iconUrl'),
size: _iconSize,
blendMode: null,
),
),
),
],
);
}
}
typedef SelectIconCallback = void Function(String iconUrl, bool isSelected);
class IconGallery extends StatelessWidget {
final Function setIcon;
const IconGallery(this.setIcon, {Key? key}) : super(key: key);
final String selectedIcon;
final SelectIconCallback onSelectIcon;
final Widget? defaultOption;
const IconGallery({
super.key,
required this.selectedIcon,
required this.onSelectIcon,
this.defaultOption,
});
Future<List<String>> _getIcons(BuildContext context) async {
final manifestContent =
@ -403,23 +488,29 @@ class IconGallery extends StatelessWidget {
return FutureBuilder<List<String>>(
future: _getIcons(context),
builder: (BuildContext context, AsyncSnapshot<List<String>> snapshot) {
if (snapshot.hasData) {
if (snapshot.hasData && snapshot.data!.isNotEmpty) {
return GridView.count(
padding: const EdgeInsets.all(20),
crossAxisCount: 5,
children: (snapshot.data ?? []).map((String iconUrl) {
return IconOption(
FlowySvgData('emoji/$iconUrl'),
iconUrl,
setIcon,
);
}).toList(),
);
} else {
return const Center(
child: CircularProgressIndicator(),
mainAxisSpacing: 4,
crossAxisSpacing: 4,
children: [
if (defaultOption != null) defaultOption!,
...snapshot.data!
.mapIndexed(
(int index, String iconUrl) => IconOption(
emoji: FlowySvgData('emoji/$iconUrl'),
iconUrl: iconUrl,
onSelectIcon: onSelectIcon,
isSelected: iconUrl == selectedIcon,
),
)
.toList(),
],
);
}
return const Center(child: CircularProgressIndicator());
},
);
}
@ -428,21 +519,34 @@ class IconGallery extends StatelessWidget {
class IconOption extends StatelessWidget {
final FlowySvgData emoji;
final String iconUrl;
final Function setIcon;
final SelectIconCallback onSelectIcon;
final bool isSelected;
IconOption(this.emoji, this.iconUrl, this.setIcon, {Key? key})
: super(key: ValueKey(emoji));
IconOption({
required this.emoji,
required this.iconUrl,
required this.onSelectIcon,
required this.isSelected,
}) : super(key: ValueKey(emoji));
@override
Widget build(BuildContext context) {
return InkWell(
borderRadius: Corners.s6Border,
borderRadius: Corners.s8Border,
hoverColor: Theme.of(context).colorScheme.tertiaryContainer,
onTap: () => setIcon(iconUrl),
child: FlowySvg(
emoji,
size: _iconSize,
blendMode: null,
onTap: () => onSelectIcon(iconUrl, isSelected),
child: DecoratedBox(
decoration: BoxDecoration(
color: isSelected
? Theme.of(context).colorScheme.primary
: Colors.transparent,
borderRadius: Corners.s8Border,
),
child: FlowySvg(
emoji,
size: _iconSize,
blendMode: null,
),
),
);
}

View File

@ -0,0 +1,20 @@
import 'package:flutter/material.dart';
const _tooltipWaitDuration = Duration(milliseconds: 300);
class FlowyTooltip {
static Tooltip delayedTooltip({
String? message,
InlineSpan? richMessage,
bool? preferBelow,
Widget? child,
}) {
return Tooltip(
waitDuration: _tooltipWaitDuration,
message: message,
richMessage: richMessage,
preferBelow: preferBelow,
child: child,
);
}
}

View File

@ -0,0 +1,82 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/util/color_generator/color_generator.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra/size.dart';
import 'package:flowy_infra_ui/style_widget/text.dart';
import 'package:flutter/material.dart';
const double _smallSize = 28;
const double _largeSize = 56;
class UserAvatar extends StatelessWidget {
const UserAvatar({
super.key,
required this.iconUrl,
required this.name,
this.isLarge = false,
});
final String iconUrl;
final String name;
final bool isLarge;
@override
Widget build(BuildContext context) {
final size = isLarge ? _largeSize : _smallSize;
if (iconUrl.isEmpty) {
final String nameOrDefault = _userName(name);
final Color color = ColorGenerator().generateColorFromString(name);
const initialsCount = 2;
// Taking the first letters of the name components and limiting to 2 elements
final nameInitials = nameOrDefault
.split(' ')
.where((element) => element.isNotEmpty)
.take(initialsCount)
.map((element) => element[0].toUpperCase())
.join('');
return Container(
width: size,
height: size,
alignment: Alignment.center,
decoration: BoxDecoration(
color: color,
shape: BoxShape.circle,
),
child: FlowyText.semibold(
nameInitials,
color: Colors.white,
fontSize: isLarge
? nameInitials.length == initialsCount
? 20
: 26
: nameInitials.length == initialsCount
? 12
: 14,
),
);
}
return SizedBox.square(
dimension: size,
child: ClipRRect(
borderRadius: Corners.s5Border,
child: CircleAvatar(
backgroundColor: Colors.transparent,
child: FlowySvg(
FlowySvgData('emoji/$iconUrl'),
blendMode: null,
),
),
),
);
}
/// Return the user name, if the user name is empty,
/// return the default user name.
String _userName(String name) =>
name.isEmpty ? LocaleKeys.defaultUsername.tr() : name;
}

View File

@ -118,6 +118,15 @@ class HoverStyle {
this.hoverColor,
this.foregroundColorOnHover,
});
const HoverStyle.transparent({
this.borderColor = Colors.transparent,
this.borderWidth = 0,
this.borderRadius = const BorderRadius.all(Radius.circular(6)),
this.contentMargin = EdgeInsets.zero,
this.backgroundColor = Colors.transparent,
this.foregroundColorOnHover,
}) : hoverColor = Colors.transparent;
}
class FlowyHoverContainer extends StatelessWidget {

View File

@ -258,7 +258,6 @@
},
"user": {
"name": "اسم",
"icon": "أيقونة",
"selectAnIcon": "حدد أيقونة",
"pleaseInputYourOpenAIKey": "الرجاء إدخال مفتاح OpenAI الخاص بك"
}

View File

@ -240,7 +240,6 @@
},
"user": {
"name": "Nom",
"icon": "Icona",
"selectAnIcon": "Seleccioneu una icona",
"pleaseInputYourOpenAIKey": "si us plau, introduïu la vostra clau OpenAI"
}

View File

@ -249,7 +249,6 @@
},
"user": {
"name": "Name",
"icon": "Symbol",
"selectAnIcon": "Wählen Sie ein Symbol aus",
"pleaseInputYourOpenAIKey": "Bitte geben Sie Ihren OpenAI-Schlüssel ein"
}

View File

@ -331,7 +331,7 @@
"user": {
"name": "Name",
"email": "Email",
"icon": "Icon",
"tooltipSelectIcon": "Select icon",
"selectAnIcon": "Select an icon",
"pleaseInputYourOpenAIKey": "please input your OpenAI key",
"clickToLogout": "Click to logout the current user"

View File

@ -246,7 +246,6 @@
},
"user": {
"name": "Nombre",
"icon": "Icono",
"selectAnIcon": "Seleccione un icono",
"pleaseInputYourOpenAIKey": "por favor ingrese su clave OpenAI"
}

View File

@ -258,7 +258,6 @@
},
"user": {
"name": "Izena",
"icon": "Ikonoa",
"selectAnIcon": "Hautatu ikono bat",
"pleaseInputYourOpenAIKey": "mesedez sartu zure OpenAI gakoa"
}

View File

@ -296,7 +296,6 @@
},
"user": {
"name": "نام",
"icon": "آیکون",
"selectAnIcon": "انتخاب یک آیکون",
"pleaseInputYourOpenAIKey": "لطفا کلید OpenAI خود را وارد کنید",
"clickToLogout": "برای خروج از کاربر فعلی کلیک کنید"

View File

@ -240,7 +240,6 @@
},
"user": {
"name": "Nom",
"icon": "Icône",
"selectAnIcon": "Sélectionnez une icône",
"pleaseInputYourOpenAIKey": "veuillez entrer votre clé OpenAI"
}

View File

@ -250,7 +250,6 @@
},
"user": {
"name": "Nom",
"icon": "Icône",
"selectAnIcon": "Sélectionnez une icône",
"pleaseInputYourOpenAIKey": "veuillez entrer votre clé OpenAI"
}

View File

@ -240,7 +240,6 @@
},
"user": {
"name": "Név",
"icon": "Ikon",
"selectAnIcon": "Válasszon ki egy ikont",
"pleaseInputYourOpenAIKey": "kérjük, adja meg OpenAI kulcsát"
}

View File

@ -246,7 +246,6 @@
},
"user": {
"name": "Nama",
"icon": "Ikon",
"selectAnIcon": "Pilih ikon",
"pleaseInputYourOpenAIKey": "silakan masukkan kunci OpenAI Anda"
}

View File

@ -241,7 +241,6 @@
},
"user": {
"name": "Nome",
"icon": "Icona",
"selectAnIcon": "Seleziona un'icona",
"pleaseInputYourOpenAIKey": "inserisci la tua chiave OpenAI"
},
@ -608,4 +607,4 @@
"deleteContentTitle": "Sei sicuro di voler eliminare {pageType}?",
"deleteContentCaption": "se elimini questo {pageType}, puoi ripristinarlo dal cestino."
}
}
}

View File

@ -240,7 +240,6 @@
},
"user": {
"name": "名前",
"icon": "アイコン",
"selectAnIcon": "アイコンを選択してください",
"pleaseInputYourOpenAIKey": "OpenAI キーを入力してください"
}

View File

@ -252,7 +252,6 @@
},
"user": {
"name": "이름",
"icon": "상",
"selectAnIcon": "아이콘을 선택하세요",
"pleaseInputYourOpenAIKey": "OpenAI 키를 입력하십시오"
}

View File

@ -240,7 +240,6 @@
},
"user": {
"name": "Nazwa",
"icon": "Ikona",
"selectAnIcon": "Wybierz ikonę",
"pleaseInputYourOpenAIKey": "wprowadź swój klucz OpenAI"
}

View File

@ -291,7 +291,6 @@
},
"user": {
"name": "Nome",
"icon": "Ícone",
"selectAnIcon": "Escolha um ícone",
"pleaseInputYourOpenAIKey": "por favor insira sua chave OpenAI",
"email": "E-mail",

View File

@ -269,7 +269,6 @@
},
"user": {
"name": "Nome",
"icon": "Ícone",
"selectAnIcon": "Selecione um ícone",
"pleaseInputYourOpenAIKey": "por favor insira sua chave OpenAI",
"email": "E-mail",

View File

@ -265,7 +265,6 @@
},
"user": {
"name": "Имя",
"icon": "Иконка",
"selectAnIcon": "Выбрать иконку",
"pleaseInputYourOpenAIKey": "Введите токен OpenAI"
}

View File

@ -250,7 +250,6 @@
},
"user": {
"name": "namn",
"icon": "Ikon",
"selectAnIcon": "Välj en ikon",
"pleaseInputYourOpenAIKey": "vänligen ange din OpenAI-nyckel"
}
@ -597,4 +596,4 @@
"deleteContentTitle": "Är du säker på att du vill ta bort {pageType}?",
"deleteContentCaption": "om du tar bort denna {pageType} kan du återställa den från papperskorgen."
}
}
}

View File

@ -240,7 +240,6 @@
},
"user": {
"name": "İsim",
"icon": "Simge",
"selectAnIcon": "Bir simge seçin",
"pleaseInputYourOpenAIKey": "lütfen OpenAI anahtarınızı girin"
}

View File

@ -331,7 +331,6 @@
"user": {
"name": "نام",
"email": "ای میل",
"icon": "آئیکن",
"selectAnIcon": "آئیکن منتخب کریں",
"pleaseInputYourOpenAIKey": "براہ کرم اپنی OpenAI کی درج کریں",
"clickToLogout": "موجودہ صارف سے لاگ آؤٹ کرنے کے لیے کلک کریں"

View File

@ -267,7 +267,6 @@
},
"user": {
"name": "名字",
"icon": "图标",
"selectAnIcon": "选择一个图标",
"pleaseInputYourOpenAIKey": "请输入您的 OpenAI 密钥"
}

View File

@ -258,7 +258,6 @@
},
"user": {
"name": "名稱",
"icon": "圖標",
"selectAnIcon": "選擇圖標",
"pleaseInputYourOpenAIKey": "請輸入你的 OpenAI 密鑰"
}