Add Scribe mobile
Same features than Scribe desktop but as a full screen modal instead of a context menu. Reuse menu and submenu components. Reuse suggestion components. Reuse 99% of Scribe desktop style.
This commit is contained in:
@@ -46,3 +46,6 @@ export 'scribe/ai/presentation/widgets/modal/suggestion/ai_scribe_suggestion_suc
|
|||||||
export 'scribe/ai/presentation/widgets/modal/suggestion/ai_scribe_suggestion_success_list_actions.dart';
|
export 'scribe/ai/presentation/widgets/modal/suggestion/ai_scribe_suggestion_success_list_actions.dart';
|
||||||
export 'scribe/ai/presentation/widgets/overlay/ai_selection_overlay.dart';
|
export 'scribe/ai/presentation/widgets/overlay/ai_selection_overlay.dart';
|
||||||
export 'scribe/ai/presentation/widgets/search/ai_scribe_bar.dart';
|
export 'scribe/ai/presentation/widgets/search/ai_scribe_bar.dart';
|
||||||
|
export 'scribe/ai/presentation/widgets/mobile/ai_scribe_mobile_actions_bottom_sheet.dart';
|
||||||
|
export 'scribe/ai/presentation/widgets/mobile/ai_scribe_mobile_actions_item.dart';
|
||||||
|
export 'scribe/ai/presentation/widgets/mobile/ai_scribe_mobile_suggestion_bottom_sheet.dart';
|
||||||
|
|||||||
@@ -181,4 +181,7 @@ abstract final class AIScribeSizes {
|
|||||||
|
|
||||||
static const EdgeInsetsGeometry sendIconPadding =
|
static const EdgeInsetsGeometry sendIconPadding =
|
||||||
EdgeInsetsDirectional.all(8);
|
EdgeInsetsDirectional.all(8);
|
||||||
|
|
||||||
|
static const EdgeInsetsGeometry backIconPadding =
|
||||||
|
EdgeInsetsDirectional.only(end: 8.0, top: 8.0, bottom: 8.0);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'package:core/presentation/resources/image_paths.dart';
|
import 'package:core/presentation/resources/image_paths.dart';
|
||||||
|
import 'package:core/utils/platform_info.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:get/get.dart';
|
import 'package:get/get.dart';
|
||||||
import 'package:scribe/scribe.dart';
|
import 'package:scribe/scribe.dart';
|
||||||
@@ -16,21 +17,30 @@ class AiScribeModalManager {
|
|||||||
ModalPlacement? preferredPlacement,
|
ModalPlacement? preferredPlacement,
|
||||||
ModalCrossAxisAlignment crossAxisAlignment = ModalCrossAxisAlignment.center,
|
ModalCrossAxisAlignment crossAxisAlignment = ModalCrossAxisAlignment.center,
|
||||||
}) async {
|
}) async {
|
||||||
final ContextSubmenuController submenuController = ContextSubmenuController();
|
final AIAction? aiAction;
|
||||||
|
|
||||||
final aiAction = await Get.dialog<AIAction>(
|
if (PlatformInfo.isMobile) {
|
||||||
AiScribeModalWidget(
|
aiAction = await showMobileAIScribeMenuModal(
|
||||||
imagePaths: imagePaths,
|
imagePaths: imagePaths,
|
||||||
content: content,
|
|
||||||
availableCategories: availableCategories,
|
availableCategories: availableCategories,
|
||||||
buttonPosition: buttonPosition,
|
);
|
||||||
buttonSize: buttonSize,
|
} else {
|
||||||
preferredPlacement: preferredPlacement,
|
final ContextSubmenuController submenuController = ContextSubmenuController();
|
||||||
crossAxisAlignment: crossAxisAlignment,
|
|
||||||
submenuController: submenuController,
|
aiAction = await Get.dialog<AIAction>(
|
||||||
),
|
AiScribeModalWidget(
|
||||||
barrierColor: AIScribeColors.dialogBarrier,
|
imagePaths: imagePaths,
|
||||||
).whenComplete(submenuController.dispose);
|
content: content,
|
||||||
|
availableCategories: availableCategories,
|
||||||
|
buttonPosition: buttonPosition,
|
||||||
|
buttonSize: buttonSize,
|
||||||
|
preferredPlacement: preferredPlacement,
|
||||||
|
crossAxisAlignment: crossAxisAlignment,
|
||||||
|
submenuController: submenuController,
|
||||||
|
),
|
||||||
|
barrierColor: AIScribeColors.dialogBarrier,
|
||||||
|
).whenComplete(submenuController.dispose);
|
||||||
|
}
|
||||||
|
|
||||||
if (aiAction != null) {
|
if (aiAction != null) {
|
||||||
await showAIScribeSuggestionModal(
|
await showAIScribeSuggestionModal(
|
||||||
@@ -56,18 +66,70 @@ class AiScribeModalManager {
|
|||||||
ModalPlacement? preferredPlacement,
|
ModalPlacement? preferredPlacement,
|
||||||
ModalCrossAxisAlignment crossAxisAlignment = ModalCrossAxisAlignment.center,
|
ModalCrossAxisAlignment crossAxisAlignment = ModalCrossAxisAlignment.center,
|
||||||
}) async {
|
}) async {
|
||||||
await Get.dialog<String?>(
|
if (PlatformInfo.isMobile) {
|
||||||
AiScribeSuggestionWidget(
|
await showMobileAIScribeSuggestionModal(
|
||||||
aiAction: aiAction,
|
aiAction: aiAction,
|
||||||
imagePaths: imagePaths,
|
imagePaths: imagePaths,
|
||||||
content: content,
|
content: content,
|
||||||
buttonPosition: buttonPosition,
|
|
||||||
buttonSize: buttonSize,
|
|
||||||
preferredPlacement: preferredPlacement,
|
|
||||||
crossAxisAlignment: crossAxisAlignment,
|
|
||||||
onSelectAiScribeSuggestionAction: onSelectAiScribeSuggestionAction,
|
onSelectAiScribeSuggestionAction: onSelectAiScribeSuggestionAction,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await Get.dialog<String?>(
|
||||||
|
AiScribeSuggestionWidget(
|
||||||
|
aiAction: aiAction,
|
||||||
|
imagePaths: imagePaths,
|
||||||
|
content: content,
|
||||||
|
buttonPosition: buttonPosition,
|
||||||
|
buttonSize: buttonSize,
|
||||||
|
preferredPlacement: preferredPlacement,
|
||||||
|
crossAxisAlignment: crossAxisAlignment,
|
||||||
|
onSelectAiScribeSuggestionAction: onSelectAiScribeSuggestionAction,
|
||||||
|
),
|
||||||
|
barrierColor: AIScribeColors.dialogBarrier,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<AIAction?> showMobileAIScribeMenuModal({
|
||||||
|
required ImagePaths imagePaths,
|
||||||
|
required List<AIScribeMenuCategory> availableCategories,
|
||||||
|
}) async {
|
||||||
|
final context = Get.context;
|
||||||
|
|
||||||
|
if (context == null) return null;
|
||||||
|
|
||||||
|
return await showModalBottomSheet<AIAction>(
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
useSafeArea: true,
|
||||||
|
builder: (context) => AiScribeMobileActionsBottomSheet(
|
||||||
|
imagePaths: imagePaths,
|
||||||
|
availableCategories: availableCategories,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<void> showMobileAIScribeSuggestionModal({
|
||||||
|
required AIAction aiAction,
|
||||||
|
required ImagePaths imagePaths,
|
||||||
|
required OnSelectAiScribeSuggestionAction onSelectAiScribeSuggestionAction,
|
||||||
|
String? content,
|
||||||
|
}) async {
|
||||||
|
final context = Get.context;
|
||||||
|
|
||||||
|
if (context == null) return;
|
||||||
|
|
||||||
|
await showModalBottomSheet(
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
useSafeArea: true,
|
||||||
|
isDismissible: true,
|
||||||
|
builder: (context) => AiScribeMobileSuggestionBottomSheet(
|
||||||
|
aiAction: aiAction,
|
||||||
|
imagePaths: imagePaths,
|
||||||
|
content: content,
|
||||||
|
onSelectAction: onSelectAiScribeSuggestionAction,
|
||||||
),
|
),
|
||||||
barrierColor: AIScribeColors.dialogBarrier,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+206
@@ -0,0 +1,206 @@
|
|||||||
|
import 'package:core/presentation/extensions/color_extension.dart';
|
||||||
|
import 'package:core/presentation/resources/image_paths.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:scribe/scribe.dart';
|
||||||
|
|
||||||
|
class AiScribeMobileActionsBottomSheet extends StatefulWidget {
|
||||||
|
final ImagePaths imagePaths;
|
||||||
|
final List<AIScribeMenuCategory> availableCategories;
|
||||||
|
|
||||||
|
const AiScribeMobileActionsBottomSheet({
|
||||||
|
super.key,
|
||||||
|
required this.imagePaths,
|
||||||
|
required this.availableCategories,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<AiScribeMobileActionsBottomSheet> createState() =>
|
||||||
|
_AiScribeMobileActionsBottomSheetState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AiScribeMobileActionsBottomSheetState
|
||||||
|
extends State<AiScribeMobileActionsBottomSheet> {
|
||||||
|
final ValueNotifier<AiScribeCategoryContextMenuAction?> _selectedCategory =
|
||||||
|
ValueNotifier(null);
|
||||||
|
|
||||||
|
void _onActionSelected(AiScribeContextMenuAction menuAction) {
|
||||||
|
if (menuAction is AiScribeActionContextMenuAction) {
|
||||||
|
Navigator.of(context).pop(PredefinedAction(menuAction.action));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onCustomPromptSubmit(String prompt) {
|
||||||
|
Navigator.of(context).pop(CustomPromptAction(prompt));
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onCategorySelected(AiScribeCategoryContextMenuAction category) {
|
||||||
|
_selectedCategory.value = category;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _goBackToCategories() {
|
||||||
|
_selectedCategory.value = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildHeader(BuildContext context, ScribeLocalizations localizations) {
|
||||||
|
return Container(
|
||||||
|
padding: AIScribeSizes.suggestionHeaderPadding,
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
ValueListenableBuilder<AiScribeCategoryContextMenuAction?>(
|
||||||
|
valueListenable: _selectedCategory,
|
||||||
|
builder: (context, selectedCategory, _) {
|
||||||
|
return selectedCategory != null
|
||||||
|
? GestureDetector(
|
||||||
|
onTap: _goBackToCategories,
|
||||||
|
child: Padding(
|
||||||
|
padding: AIScribeSizes.backIconPadding,
|
||||||
|
child: Icon(
|
||||||
|
Icons.chevron_left,
|
||||||
|
size: AIScribeSizes.aiAssistantIcon,
|
||||||
|
color: AppColor.gray424244.withValues(alpha: 0.72),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: const SizedBox.shrink();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: ValueListenableBuilder<AiScribeCategoryContextMenuAction?>(
|
||||||
|
valueListenable: _selectedCategory,
|
||||||
|
builder: (context, selectedCategory, _) {
|
||||||
|
return Text(
|
||||||
|
selectedCategory?.actionName ?? localizations.aiAssistant,
|
||||||
|
style: AIScribeTextStyles.suggestionTitle,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(
|
||||||
|
Icons.close,
|
||||||
|
size: AIScribeSizes.aiAssistantIcon,
|
||||||
|
),
|
||||||
|
color: AppColor.gray424244.withValues(alpha: 0.72),
|
||||||
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildMenuListView(List<AiScribeCategoryContextMenuAction> menuActions) {
|
||||||
|
return ListView.builder(
|
||||||
|
physics: const ClampingScrollPhysics(),
|
||||||
|
shrinkWrap: true,
|
||||||
|
itemCount: menuActions.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final menuAction = menuActions[index];
|
||||||
|
return AiScribeMobileActionsItem(
|
||||||
|
menuAction: menuAction,
|
||||||
|
imagePaths: widget.imagePaths,
|
||||||
|
onCategorySelected: _onCategorySelected,
|
||||||
|
onActionSelected: _onActionSelected,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildSubmenuListView() {
|
||||||
|
return ValueListenableBuilder<AiScribeCategoryContextMenuAction?>(
|
||||||
|
valueListenable: _selectedCategory,
|
||||||
|
builder: (context, selectedCategory, _) {
|
||||||
|
return ListView.builder(
|
||||||
|
physics: const ClampingScrollPhysics(),
|
||||||
|
shrinkWrap: true,
|
||||||
|
itemCount: selectedCategory?.submenuActions?.length ?? 0,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final submenuAction = selectedCategory!.submenuActions![index];
|
||||||
|
return AiScribeSubmenuItem(
|
||||||
|
menuAction: submenuAction,
|
||||||
|
onSelectAction: _onActionSelected,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildBottomBar(BuildContext context) {
|
||||||
|
return Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const Divider(
|
||||||
|
height: 1,
|
||||||
|
thickness: 1,
|
||||||
|
color: AppColor.colorDividerComposer,
|
||||||
|
),
|
||||||
|
Padding(
|
||||||
|
padding: EdgeInsets.only(
|
||||||
|
bottom: MediaQuery.of(context).viewInsets.bottom + 8.0,
|
||||||
|
left: 16.0,
|
||||||
|
right: 16.0,
|
||||||
|
top: 8.0,
|
||||||
|
),
|
||||||
|
child: AIScribeBar(
|
||||||
|
onCustomPrompt: _onCustomPromptSubmit,
|
||||||
|
imagePaths: widget.imagePaths,
|
||||||
|
boxShadow: const [],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_selectedCategory.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final localizations = ScribeLocalizations.of(context);
|
||||||
|
final menuActions = widget.availableCategories
|
||||||
|
.map((category) => AiScribeCategoryContextMenuAction(
|
||||||
|
category,
|
||||||
|
localizations,
|
||||||
|
widget.imagePaths,
|
||||||
|
))
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
height: double.infinity,
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
color: AIScribeColors.background,
|
||||||
|
),
|
||||||
|
child: SafeArea(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
_buildHeader(context, localizations),
|
||||||
|
Flexible(
|
||||||
|
child: ValueListenableBuilder<AiScribeCategoryContextMenuAction?>(
|
||||||
|
valueListenable: _selectedCategory,
|
||||||
|
builder: (context, selectedCategory, _) {
|
||||||
|
return selectedCategory == null
|
||||||
|
? _buildMenuListView(menuActions)
|
||||||
|
: _buildSubmenuListView();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
_buildBottomBar(context),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import 'package:core/presentation/resources/image_paths.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:scribe/scribe.dart';
|
||||||
|
|
||||||
|
class AiScribeMobileActionsItem extends StatelessWidget {
|
||||||
|
final AiScribeContextMenuAction menuAction;
|
||||||
|
final ImagePaths imagePaths;
|
||||||
|
final ValueChanged<AiScribeCategoryContextMenuAction>? onCategorySelected;
|
||||||
|
final ValueChanged<AiScribeContextMenuAction> onActionSelected;
|
||||||
|
|
||||||
|
const AiScribeMobileActionsItem({
|
||||||
|
super.key,
|
||||||
|
required this.menuAction,
|
||||||
|
required this.imagePaths,
|
||||||
|
this.onCategorySelected,
|
||||||
|
required this.onActionSelected,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
// When category
|
||||||
|
if (menuAction.hasSubmenu) {
|
||||||
|
return AiScribeMenuItem(
|
||||||
|
menuAction: menuAction,
|
||||||
|
imagePaths: imagePaths,
|
||||||
|
onSelectAction: (menuAction) {
|
||||||
|
if (menuAction is AiScribeCategoryContextMenuAction) {
|
||||||
|
onCategorySelected?.call(menuAction);
|
||||||
|
} else {
|
||||||
|
onActionSelected.call(menuAction);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// When action alongside category
|
||||||
|
final submenuActions = menuAction.submenuActions;
|
||||||
|
if (submenuActions?.length == 1) {
|
||||||
|
return AiScribeSubmenuItem(
|
||||||
|
menuAction: submenuActions!.first,
|
||||||
|
onSelectAction: onActionSelected,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// When action inside category
|
||||||
|
return AiScribeMenuItem(
|
||||||
|
menuAction: menuAction,
|
||||||
|
imagePaths: imagePaths,
|
||||||
|
onSelectAction: onActionSelected,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
+96
@@ -0,0 +1,96 @@
|
|||||||
|
import 'package:core/presentation/resources/image_paths.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:scribe/scribe.dart';
|
||||||
|
|
||||||
|
class AiScribeMobileSuggestionBottomSheet extends StatefulWidget {
|
||||||
|
final AIAction aiAction;
|
||||||
|
final ImagePaths imagePaths;
|
||||||
|
final String? content;
|
||||||
|
final OnSelectAiScribeSuggestionAction onSelectAction;
|
||||||
|
|
||||||
|
const AiScribeMobileSuggestionBottomSheet({
|
||||||
|
super.key,
|
||||||
|
required this.aiAction,
|
||||||
|
required this.imagePaths,
|
||||||
|
required this.onSelectAction,
|
||||||
|
this.content,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<AiScribeMobileSuggestionBottomSheet> createState() =>
|
||||||
|
_AiScribeMobileSuggestionBottomSheetState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AiScribeMobileSuggestionBottomSheetState
|
||||||
|
extends State<AiScribeMobileSuggestionBottomSheet>
|
||||||
|
with AiScribeSuggestionStateMixin {
|
||||||
|
@override
|
||||||
|
AIAction get aiAction => widget.aiAction;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String? get content => widget.content;
|
||||||
|
|
||||||
|
@override
|
||||||
|
ImagePaths get imagePaths => widget.imagePaths;
|
||||||
|
|
||||||
|
@override
|
||||||
|
OnSelectAiScribeSuggestionAction get onSelectAction =>
|
||||||
|
widget.onSelectAction;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final localizations = ScribeLocalizations.of(context);
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
height: double.infinity,
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
color: AIScribeColors.background,
|
||||||
|
),
|
||||||
|
child: SafeArea(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: AIScribeSizes.suggestionHeaderPadding,
|
||||||
|
child: AiScribeSuggestionHeader(
|
||||||
|
title: aiAction.getLabel(localizations),
|
||||||
|
imagePaths: imagePaths,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Flexible(
|
||||||
|
child: buildStateContent(context),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget buildLoadingState() {
|
||||||
|
return Padding(
|
||||||
|
padding: AIScribeSizes.suggestionContentPadding,
|
||||||
|
child: super.buildLoadingState(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget buildErrorState() {
|
||||||
|
return Padding(
|
||||||
|
padding: AIScribeSizes.suggestionContentPadding,
|
||||||
|
child: super.buildErrorState(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget buildSuccessState(
|
||||||
|
String suggestionText,
|
||||||
|
bool hasContent,
|
||||||
|
) {
|
||||||
|
return Padding(
|
||||||
|
padding: AIScribeSizes.suggestionContentPadding,
|
||||||
|
child: super.buildSuccessState(suggestionText, hasContent),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user