feat(ui): 新增登录入口与我的页预览
This commit is contained in:
1 parent
21d457fd98
commit
fecc5b0d27
10 files changed
+999
-627
No files matched your search
@@ -60,6 +60,7 @@ class _AuthPageState extends State<AuthPage> {
|
|||||||
late final FocusNode _emailFocus;
|
late final FocusNode _emailFocus;
|
||||||
late final FocusNode _passwordFocus;
|
late final FocusNode _passwordFocus;
|
||||||
bool _obscurePassword = true;
|
bool _obscurePassword = true;
|
||||||
|
bool _emailEntryOpen = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -243,7 +244,13 @@ class _AuthPageState extends State<AuthPage> {
|
|||||||
tooltip: MaterialLocalizations.of(
|
tooltip: MaterialLocalizations.of(
|
||||||
context,
|
context,
|
||||||
).backButtonTooltip,
|
).backButtonTooltip,
|
||||||
onPressed: () => Navigator.of(context).pop(),
|
onPressed: () {
|
||||||
|
if (_emailEntryOpen) {
|
||||||
|
setState(() => _emailEntryOpen = false);
|
||||||
|
} else {
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
}
|
||||||
|
},
|
||||||
icon: const Icon(
|
icon: const Icon(
|
||||||
Icons.arrow_back_rounded,
|
Icons.arrow_back_rounded,
|
||||||
color: AppColors.onPrimary,
|
color: AppColors.onPrimary,
|
||||||
@@ -272,16 +279,107 @@ class _AuthPageState extends State<AuthPage> {
|
|||||||
child: child,
|
child: child,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: Column(
|
child: _emailEntryOpen
|
||||||
|
? Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
_AuthBrand(tagline: l10n.authBrandTagline, compact: compact),
|
_AuthBrand(tagline: l10n.authBrandTagline, compact: compact),
|
||||||
SizedBox(height: compact ? _compactBrandGap : 28),
|
SizedBox(height: compact ? _compactBrandGap : 28),
|
||||||
_authCard(l10n),
|
_authCard(l10n),
|
||||||
],
|
],
|
||||||
),
|
)
|
||||||
|
: _landing(l10n),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
Widget _landing(AppLocalizations l10n) => Column(
|
||||||
|
key: const Key('auth-landing'),
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Center(
|
||||||
|
child: Container(
|
||||||
|
key: const Key('auth-brand-logo'),
|
||||||
|
width: 64,
|
||||||
|
height: 64,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(18),
|
||||||
|
gradient: const LinearGradient(
|
||||||
|
begin: Alignment.topLeft,
|
||||||
|
end: Alignment.bottomRight,
|
||||||
|
colors: [AppColors.blue, AppColors.secondary],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: const Icon(
|
||||||
|
Icons.play_arrow_rounded,
|
||||||
|
color: AppColors.onPrimary,
|
||||||
|
size: 32,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
Text(
|
||||||
|
l10n.fruitShortsBrand,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontFamily: 'Outfit',
|
||||||
|
color: AppColors.onSurface,
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Text(
|
||||||
|
l10n.authLandingSubtitle,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: const TextStyle(color: Color(0xFF9A96A3), fontSize: 11),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 22),
|
||||||
|
_AuthEntryButton(
|
||||||
|
key: const Key('auth-entry-google'),
|
||||||
|
label: 'G ${l10n.authContinueGoogle}',
|
||||||
|
background: Colors.white,
|
||||||
|
foreground: const Color(0xFF1A1A1A),
|
||||||
|
leadingColor: const Color(0xFF4285F4),
|
||||||
|
onTap: () => _comingSoon(l10n),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 11),
|
||||||
|
_AuthEntryButton(
|
||||||
|
key: const Key('auth-entry-phone'),
|
||||||
|
label: l10n.authPhoneSignIn,
|
||||||
|
icon: Icons.phone_android_rounded,
|
||||||
|
background: AppColors.input,
|
||||||
|
foreground: AppColors.onSurface,
|
||||||
|
onTap: () => _comingSoon(l10n),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 11),
|
||||||
|
_AuthEntryButton(
|
||||||
|
key: const Key('auth-entry-email'),
|
||||||
|
label: l10n.authEmailSignIn,
|
||||||
|
icon: Icons.mail_outline_rounded,
|
||||||
|
background: AppColors.input,
|
||||||
|
foreground: AppColors.onSurface,
|
||||||
|
onTap: () => setState(() => _emailEntryOpen = true),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 18),
|
||||||
|
Text(
|
||||||
|
l10n.authLegal,
|
||||||
|
key: const Key('auth-legal'),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Color(0xFF6B6772),
|
||||||
|
fontSize: 9,
|
||||||
|
height: 1.6,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
void _comingSoon(AppLocalizations l10n) {
|
||||||
|
ScaffoldMessenger.of(
|
||||||
|
context,
|
||||||
|
).showSnackBar(SnackBar(content: Text(l10n.comingSoon)));
|
||||||
|
}
|
||||||
|
|
||||||
Widget _authCard(AppLocalizations l10n) => _AuthCard(
|
Widget _authCard(AppLocalizations l10n) => _AuthCard(
|
||||||
state: _AuthCardState(
|
state: _AuthCardState(
|
||||||
l10n: l10n,
|
l10n: l10n,
|
||||||
@@ -320,3 +418,57 @@ class _AuthPageState extends State<AuthPage> {
|
|||||||
setState(() => _logic.toggleMode());
|
setState(() => _logic.toggleMode());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _AuthEntryButton extends StatelessWidget {
|
||||||
|
const _AuthEntryButton({
|
||||||
|
super.key,
|
||||||
|
required this.label,
|
||||||
|
required this.background,
|
||||||
|
required this.foreground,
|
||||||
|
required this.onTap,
|
||||||
|
this.icon,
|
||||||
|
this.leadingColor,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String label;
|
||||||
|
final Color background;
|
||||||
|
final Color foreground;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
final IconData? icon;
|
||||||
|
final Color? leadingColor;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Material(
|
||||||
|
color: background,
|
||||||
|
borderRadius: BorderRadius.circular(9),
|
||||||
|
clipBehavior: Clip.antiAlias,
|
||||||
|
child: InkWell(
|
||||||
|
onTap: onTap,
|
||||||
|
child: SizedBox(
|
||||||
|
height: 44,
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
if (icon != null)
|
||||||
|
Icon(icon, size: 16, color: leadingColor ?? foreground),
|
||||||
|
if (icon != null) const SizedBox(width: 7),
|
||||||
|
Flexible(
|
||||||
|
child: Text(
|
||||||
|
label,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: TextStyle(
|
||||||
|
color: foreground,
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
+677
-619
File diff suppressed because it is too large.
Load diff
@@ -46,6 +46,8 @@ void main() {
|
|||||||
|
|
||||||
await tester.tap(find.byKey(const Key('tab-my')));
|
await tester.tap(find.byKey(const Key('tab-my')));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
await tester.drag(find.byType(ListView), const Offset(0, -520));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
expect(find.byKey(const Key('mine-account-card')), findsOneWidget);
|
expect(find.byKey(const Key('mine-account-card')), findsOneWidget);
|
||||||
expect(find.byKey(const Key('mine-account-profile')), findsOneWidget);
|
expect(find.byKey(const Key('mine-account-profile')), findsOneWidget);
|
||||||
|
|||||||
@@ -82,4 +82,7 @@ Future<void> _pumpAuth(WidgetTester tester) async {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
await tester.ensureVisible(find.byKey(const Key('auth-entry-email')));
|
||||||
|
await tester.tap(find.byKey(const Key('auth-entry-email')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
// Author: fruit.com.cn
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart';
|
||||||
|
import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart';
|
||||||
|
|
||||||
|
import 'package:fruitshorts/core/data/remote/api_client.dart';
|
||||||
|
import 'package:fruitshorts/core/data/remote/auth_api.dart';
|
||||||
|
import 'package:fruitshorts/core/data/services/auth_controller.dart';
|
||||||
|
import 'package:fruitshorts/core/data/services/auth_session_store.dart';
|
||||||
|
import 'package:fruitshorts/core/platform/haptics.dart';
|
||||||
|
import 'package:fruitshorts/l10n/app_localizations.dart';
|
||||||
|
import 'package:fruitshorts/pages/auth/auth_view.dart';
|
||||||
|
|
||||||
|
import '../support/fake_haptics.dart';
|
||||||
|
import '../support/fake_http_adapter.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
testWidgets('should_show_three_login_entries_and_open_email_form', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
SharedPreferencesAsyncPlatform.instance =
|
||||||
|
InMemorySharedPreferencesAsync.empty();
|
||||||
|
final auth = AuthController(
|
||||||
|
AuthApi(
|
||||||
|
ApiClient(
|
||||||
|
baseUrl: 'http://127.0.0.1:1',
|
||||||
|
adapter: FakeHttpAdapter((_) async => FakeHttpResponse('{}', 500)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
AuthSessionStore(),
|
||||||
|
);
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MultiProvider(
|
||||||
|
providers: [
|
||||||
|
ChangeNotifierProvider<AuthController>.value(value: auth),
|
||||||
|
Provider<Haptics>.value(value: FakeHaptics()),
|
||||||
|
],
|
||||||
|
child: MaterialApp(
|
||||||
|
locale: const Locale('en'),
|
||||||
|
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||||
|
supportedLocales: AppLocalizations.supportedLocales,
|
||||||
|
home: const AuthPage(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.byKey(const Key('auth-entry-google')), findsOneWidget);
|
||||||
|
expect(find.byKey(const Key('auth-entry-phone')), findsOneWidget);
|
||||||
|
expect(find.byKey(const Key('auth-entry-email')), findsOneWidget);
|
||||||
|
expect(find.byKey(const Key('auth-legal')), findsOneWidget);
|
||||||
|
|
||||||
|
await tester.tap(find.byKey(const Key('auth-entry-google')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('Coming soon'), findsOneWidget);
|
||||||
|
|
||||||
|
await tester.tap(find.byKey(const Key('auth-entry-email')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.byKey(const Key('auth-email')), findsOneWidget);
|
||||||
|
expect(find.byKey(const Key('auth-password')), findsOneWidget);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -59,6 +59,9 @@ void main() {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
await tester.ensureVisible(find.byKey(const Key('auth-entry-email')));
|
||||||
|
await tester.tap(find.byKey(const Key('auth-entry-email')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
}
|
}
|
||||||
|
|
||||||
testWidgets('should_show_email_error_and_stay_on_page_when_form_is_empty', (
|
testWidgets('should_show_email_error_and_stay_on_page_when_form_is_empty', (
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ void main() {
|
|||||||
WidgetTester tester, {
|
WidgetTester tester, {
|
||||||
AuthApi? api,
|
AuthApi? api,
|
||||||
FakeHaptics? haptics,
|
FakeHaptics? haptics,
|
||||||
|
bool openEmail = true,
|
||||||
}) async {
|
}) async {
|
||||||
final auth = AuthController(
|
final auth = AuthController(
|
||||||
api ??
|
api ??
|
||||||
@@ -57,20 +58,32 @@ void main() {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
await tester.pump(const Duration(milliseconds: 220));
|
await tester.pump(const Duration(milliseconds: 220));
|
||||||
|
if (openEmail) {
|
||||||
|
await tester.ensureVisible(find.byKey(const Key('auth-entry-email')));
|
||||||
|
await tester.tap(find.byKey(const Key('auth-entry-email')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
testWidgets(
|
testWidgets(
|
||||||
'should_render_branded_dark_layout_with_approved_tokens_when_page_opens',
|
'should_render_branded_dark_layout_with_approved_tokens_when_page_opens',
|
||||||
(tester) async {
|
(tester) async {
|
||||||
await pumpAuthPage(tester);
|
await pumpAuthPage(tester, openEmail: false);
|
||||||
|
|
||||||
expect(find.byKey(const Key('auth-brand')), findsOneWidget);
|
expect(find.byKey(const Key('auth-landing')), findsOneWidget);
|
||||||
expect(find.text('FruitShorts'), findsOneWidget);
|
expect(find.text('FruitShorts'), findsOneWidget);
|
||||||
expect(find.text('Short dramas made for Indonesia'), findsOneWidget);
|
|
||||||
expect(
|
expect(
|
||||||
tester.getSize(find.byKey(const Key('auth-brand-mark'))),
|
find.text('Sign in to sync progress, favorites and coins'),
|
||||||
const Size(48, 48),
|
findsOneWidget,
|
||||||
);
|
);
|
||||||
|
expect(
|
||||||
|
tester.getSize(find.byKey(const Key('auth-brand-logo'))),
|
||||||
|
const Size(64, 64),
|
||||||
|
);
|
||||||
|
expect(find.byKey(const Key('auth-entry-google')), findsOneWidget);
|
||||||
|
expect(find.byKey(const Key('auth-entry-phone')), findsOneWidget);
|
||||||
|
expect(find.byKey(const Key('auth-entry-email')), findsOneWidget);
|
||||||
|
expect(find.byKey(const Key('auth-legal')), findsOneWidget);
|
||||||
|
|
||||||
final glow = tester.widget<Container>(
|
final glow = tester.widget<Container>(
|
||||||
find.byKey(const Key('auth-background-glow')),
|
find.byKey(const Key('auth-background-glow')),
|
||||||
@@ -81,6 +94,9 @@ void main() {
|
|||||||
expect(color.a, lessThanOrEqualTo(0.18));
|
expect(color.a, lessThanOrEqualTo(0.18));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await tester.tap(find.byKey(const Key('auth-entry-email')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
final card = tester.widget<Container>(find.byKey(const Key('auth-card')));
|
final card = tester.widget<Container>(find.byKey(const Key('auth-card')));
|
||||||
final cardDecoration = card.decoration! as BoxDecoration;
|
final cardDecoration = card.decoration! as BoxDecoration;
|
||||||
expect(cardDecoration.color, AppColors.card);
|
expect(cardDecoration.color, AppColors.card);
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
// Author: fruit.com.cn
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart';
|
||||||
|
import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart';
|
||||||
|
|
||||||
|
import 'package:fruitshorts/app.dart';
|
||||||
|
import 'package:fruitshorts/core/data/models/watch_progress.dart';
|
||||||
|
import 'package:fruitshorts/core/data/repositories/favorite_repository.dart';
|
||||||
|
import 'package:fruitshorts/core/data/repositories/playback_repository.dart';
|
||||||
|
import 'package:fruitshorts/core/data/repositories/watch_history_repository.dart';
|
||||||
|
|
||||||
|
import '../support/fake_catalog.dart';
|
||||||
|
import '../support/fake_category.dart';
|
||||||
|
import '../support/fake_haptics.dart';
|
||||||
|
import '../support/fake_interaction_repositories.dart';
|
||||||
|
import '../support/fake_notice_repository.dart';
|
||||||
|
import '../support/widget_player.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
testWidgets('should_render_me_user_wallet_continue_and_language_rows', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
SharedPreferencesAsyncPlatform.instance =
|
||||||
|
InMemorySharedPreferencesAsync.empty();
|
||||||
|
tester.view.physicalSize = const Size(390, 844);
|
||||||
|
tester.view.devicePixelRatio = 1;
|
||||||
|
addTearDown(tester.view.resetPhysicalSize);
|
||||||
|
addTearDown(tester.view.resetDevicePixelRatio);
|
||||||
|
|
||||||
|
final catalog = FakeCatalogRepository();
|
||||||
|
await tester.pumpWidget(
|
||||||
|
LumaDramaApp(
|
||||||
|
playerFactory: createWidgetPlayer,
|
||||||
|
catalogRepository: catalog,
|
||||||
|
categoryRepository: FakeCategoryRepository(catalog: catalog),
|
||||||
|
watchHistoryRepository: InMemoryWatchHistoryRepository(
|
||||||
|
seed: const [
|
||||||
|
WatchProgress(
|
||||||
|
dramaId: 'city-lights-demo',
|
||||||
|
episodeId: 'city-lights-demo-1',
|
||||||
|
position: Duration(minutes: 2),
|
||||||
|
duration: Duration(minutes: 5),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
favoriteRepository: InMemoryFavoriteRepository(cards: catalog.dramas),
|
||||||
|
playbackRepository: FakePlaybackRepository(),
|
||||||
|
commentRepository: FakeCommentRepository(),
|
||||||
|
interactionRepository: FakeInteractionRepository(),
|
||||||
|
noticeRepository: FakeNoticeRepository(),
|
||||||
|
haptics: FakeHaptics(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.byKey(const Key('tab-my')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.byKey(const Key('mine-user-row')), findsOneWidget);
|
||||||
|
expect(find.byKey(const Key('mine-wallet')), findsOneWidget);
|
||||||
|
expect(find.byKey(const Key('mine-continue-watching')), findsOneWidget);
|
||||||
|
expect(find.byKey(const Key('mine-language')), findsOneWidget);
|
||||||
|
expect(find.byKey(const Key('mine-library-tab-0')), findsOneWidget);
|
||||||
|
expect(find.byKey(const Key('mine-library-tab-1')), findsOneWidget);
|
||||||
|
expect(find.byKey(const Key('mine-library-tab-2')), findsOneWidget);
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -46,6 +46,8 @@ void main() {
|
|||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
await tester.tap(find.byKey(const Key('tab-my')));
|
await tester.tap(find.byKey(const Key('tab-my')));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
await tester.drag(find.byType(ListView), const Offset(0, -520));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
expect(find.byKey(const Key('mine-notices')), findsOneWidget);
|
expect(find.byKey(const Key('mine-notices')), findsOneWidget);
|
||||||
await tester.tap(find.byKey(const Key('mine-notices')));
|
await tester.tap(find.byKey(const Key('mine-notices')));
|
||||||
|
|||||||
@@ -42,6 +42,8 @@ void main() {
|
|||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
await tester.tap(find.byKey(const Key('tab-my')));
|
await tester.tap(find.byKey(const Key('tab-my')));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
await tester.drag(find.byType(ListView), const Offset(0, -520));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
await tester.tap(find.text('Watch History'));
|
await tester.tap(find.text('Watch History'));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user