Add Local Notifications with deadline reminders
This commit is contained in:
@@ -0,0 +1,365 @@
|
||||
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||
import 'package:timezone/timezone.dart' as tz;
|
||||
import 'package:timezone/data/latest.dart' as tz;
|
||||
|
||||
class NotificationService {
|
||||
static final NotificationService _instance = NotificationService._internal();
|
||||
factory NotificationService() => _instance;
|
||||
NotificationService._internal();
|
||||
|
||||
final FlutterLocalNotificationsPlugin _notifications = FlutterLocalNotificationsPlugin();
|
||||
bool _isInitialized = false;
|
||||
|
||||
// Инициализация
|
||||
Future<void> initialize() async {
|
||||
if (_isInitialized) return;
|
||||
|
||||
// Инициализируем timezone
|
||||
tz.initializeTimeZones();
|
||||
|
||||
// Настройки для Android
|
||||
const androidSettings = AndroidInitializationSettings('@mipmap/ic_launcher');
|
||||
|
||||
// Настройки для iOS
|
||||
const iosSettings = DarwinInitializationSettings(
|
||||
requestAlertPermission: true,
|
||||
requestBadgePermission: true,
|
||||
requestSoundPermission: true,
|
||||
);
|
||||
|
||||
const settings = InitializationSettings(
|
||||
android: androidSettings,
|
||||
iOS: iosSettings,
|
||||
);
|
||||
|
||||
await _notifications.initialize(
|
||||
settings,
|
||||
onDidReceiveNotificationResponse: _onNotificationTapped,
|
||||
onDidReceiveBackgroundNotificationResponse: _onBackgroundNotificationTapped,
|
||||
);
|
||||
|
||||
// Запрашиваем разрешения
|
||||
await _requestPermissions();
|
||||
|
||||
_isInitialized = true;
|
||||
}
|
||||
|
||||
// Запрос разрешений
|
||||
Future<void> _requestPermissions() async {
|
||||
// Android 13+ требует запрос разрешений
|
||||
final androidPlugin = _notifications
|
||||
.resolvePlatformSpecificImplementation<AndroidFlutterLocalNotificationsPlugin>();
|
||||
|
||||
if (androidPlugin != null) {
|
||||
await androidPlugin.requestNotificationsPermission();
|
||||
}
|
||||
|
||||
// iOS
|
||||
final iosPlugin = _notifications
|
||||
.resolvePlatformSpecificImplementation<IOSFlutterLocalNotificationsPlugin>();
|
||||
|
||||
if (iosPlugin != null) {
|
||||
await iosPlugin.requestPermissions(
|
||||
alert: true,
|
||||
badge: true,
|
||||
sound: true,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Обработка нажатия на уведомление
|
||||
void _onNotificationTapped(NotificationResponse response) {
|
||||
print('Notification tapped: ${response.payload}');
|
||||
// TODO: Навигация к задаче
|
||||
}
|
||||
|
||||
// Обработка нажатия в фоне
|
||||
@pragma('vm:entry-point')
|
||||
static void _onBackgroundNotificationTapped(NotificationResponse response) {
|
||||
print('Background notification tapped: ${response.payload}');
|
||||
}
|
||||
|
||||
// Создание канала для уведомлений о задачах
|
||||
Future<void> _createTaskChannel() async {
|
||||
const channel = AndroidNotificationChannel(
|
||||
'tasks_channel',
|
||||
'Задачи',
|
||||
description: 'Уведомления о задачах и дедлайнах',
|
||||
importance: Importance.high,
|
||||
enableVibration: true,
|
||||
playSound: true,
|
||||
);
|
||||
|
||||
await _notifications
|
||||
.resolvePlatformSpecificImplementation<AndroidFlutterLocalNotificationsPlugin>()
|
||||
?.createNotificationChannel(channel);
|
||||
}
|
||||
|
||||
// Создание канала для будильников
|
||||
Future<void> _createAlarmChannel() async {
|
||||
const channel = AndroidNotificationChannel(
|
||||
'alarms_channel',
|
||||
'Будильники',
|
||||
description: 'Будильники и напоминания',
|
||||
importance: Importance.max,
|
||||
enableVibration: true,
|
||||
playSound: true,
|
||||
);
|
||||
|
||||
await _notifications
|
||||
.resolvePlatformSpecificImplementation<AndroidFlutterLocalNotificationsPlugin>()
|
||||
?.createNotificationChannel(channel);
|
||||
}
|
||||
|
||||
// Показ немедленного уведомления
|
||||
Future<void> showNotification({
|
||||
required int id,
|
||||
required String title,
|
||||
required String body,
|
||||
String? payload,
|
||||
String channelId = 'tasks_channel',
|
||||
}) async {
|
||||
if (!_isInitialized) await initialize();
|
||||
|
||||
const details = NotificationDetails(
|
||||
android: AndroidNotificationDetails(
|
||||
'tasks_channel',
|
||||
'Задачи',
|
||||
channelDescription: 'Уведомления о задачах',
|
||||
importance: Importance.high,
|
||||
priority: Priority.high,
|
||||
icon: '@mipmap/ic_launcher',
|
||||
enableVibration: true,
|
||||
playSound: true,
|
||||
),
|
||||
iOS: DarwinNotificationDetails(
|
||||
presentAlert: true,
|
||||
presentBadge: true,
|
||||
presentSound: true,
|
||||
),
|
||||
);
|
||||
|
||||
await _notifications.show(
|
||||
id,
|
||||
title,
|
||||
body,
|
||||
details,
|
||||
payload: payload,
|
||||
);
|
||||
}
|
||||
|
||||
// Планирование уведомления
|
||||
Future<void> scheduleNotification({
|
||||
required int id,
|
||||
required String title,
|
||||
required String body,
|
||||
required DateTime scheduledDate,
|
||||
String? payload,
|
||||
String channelId = 'tasks_channel',
|
||||
}) async {
|
||||
if (!_isInitialized) await initialize();
|
||||
|
||||
// Не планируем в прошлом
|
||||
if (scheduledDate.isBefore(DateTime.now())) {
|
||||
scheduledDate = DateTime.now().add(const Duration(minutes: 1));
|
||||
}
|
||||
|
||||
const details = NotificationDetails(
|
||||
android: AndroidNotificationDetails(
|
||||
'tasks_channel',
|
||||
'Задачи',
|
||||
channelDescription: 'Уведомления о задачах',
|
||||
importance: Importance.high,
|
||||
priority: Priority.high,
|
||||
icon: '@mipmap/ic_launcher',
|
||||
enableVibration: true,
|
||||
playSound: true,
|
||||
),
|
||||
iOS: DarwinNotificationDetails(
|
||||
presentAlert: true,
|
||||
presentBadge: true,
|
||||
presentSound: true,
|
||||
),
|
||||
);
|
||||
|
||||
await _notifications.zonedSchedule(
|
||||
id,
|
||||
title,
|
||||
body,
|
||||
tz.TZDateTime.from(scheduledDate, tz.local),
|
||||
details,
|
||||
androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle,
|
||||
uiLocalNotificationDateInterpretation:
|
||||
UILocalNotificationDateInterpretation.absoluteTime,
|
||||
payload: payload,
|
||||
);
|
||||
}
|
||||
|
||||
// Отмена уведомления
|
||||
Future<void> cancelNotification(int id) async {
|
||||
await _notifications.cancel(id);
|
||||
}
|
||||
|
||||
// Отмена всех уведомлений
|
||||
Future<void> cancelAllNotifications() async {
|
||||
await _notifications.cancelAll();
|
||||
}
|
||||
|
||||
// Уведомление о дедлайне задачи
|
||||
Future<void> showTaskDeadlineReminder({
|
||||
required String taskTitle,
|
||||
required DateTime deadline,
|
||||
String? taskId,
|
||||
}) async {
|
||||
final hoursUntilDeadline = deadline.difference(DateTime.now()).inHours;
|
||||
|
||||
String title;
|
||||
String body;
|
||||
|
||||
if (hoursUntilDeadline <= 0) {
|
||||
title = '🔴 Дедлайн задачи!';
|
||||
body = 'Задача "$taskTitle" должна быть выполнена прямо сейчас!';
|
||||
} else if (hoursUntilDeadline <= 1) {
|
||||
title = '🟠 Срочно! Дедлайн через час';
|
||||
body = 'Задача "$taskTitle" должна быть выполнена через $hoursUntilDeadline ч';
|
||||
} else if (hoursUntilDeadline <= 24) {
|
||||
title = '🟡 Напоминание о дедлайне';
|
||||
body = 'Задача "$taskTitle" должна быть выполнена через $hoursUntilDeadline ч';
|
||||
} else {
|
||||
final days = hoursUntilDeadline ~/ 24;
|
||||
title = '🔵 Скоро дедлайн';
|
||||
body = 'Задача "$taskTitle" должна быть выполнена через $days дн';
|
||||
}
|
||||
|
||||
await showNotification(
|
||||
id: taskId.hashCode,
|
||||
title: title,
|
||||
body: body,
|
||||
payload: taskId,
|
||||
);
|
||||
}
|
||||
|
||||
// Уведомление о новой задаче
|
||||
Future<void> showNewTaskNotification({
|
||||
required String taskTitle,
|
||||
String? taskId,
|
||||
}) async {
|
||||
await showNotification(
|
||||
id: taskId.hashCode,
|
||||
title: '📋 Новая задача',
|
||||
body: 'Задача "$taskTitle" создана',
|
||||
payload: taskId,
|
||||
);
|
||||
}
|
||||
|
||||
// Уведомление о назначении задачи
|
||||
Future<void> showTaskAssignedNotification({
|
||||
required String taskTitle,
|
||||
required String assignedBy,
|
||||
String? taskId,
|
||||
}) async {
|
||||
await showNotification(
|
||||
id: taskId.hashCode,
|
||||
title: '🎯 Вам назначена задача',
|
||||
body: '$assignedBy назначил вас на задачу "$taskTitle"',
|
||||
payload: taskId,
|
||||
);
|
||||
}
|
||||
|
||||
// Будильник
|
||||
Future<void> showAlarmNotification({
|
||||
required int id,
|
||||
required String title,
|
||||
required String body,
|
||||
}) async {
|
||||
if (!_isInitialized) await initialize();
|
||||
|
||||
const details = NotificationDetails(
|
||||
android: AndroidNotificationDetails(
|
||||
'alarms_channel',
|
||||
'Будильники',
|
||||
channelDescription: 'Будильники и напоминания',
|
||||
importance: Importance.max,
|
||||
priority: Priority.max,
|
||||
icon: '@mipmap/ic_launcher',
|
||||
enableVibration: true,
|
||||
playSound: true,
|
||||
fullScreenIntent: true,
|
||||
category: AndroidNotificationCategory.alarm,
|
||||
),
|
||||
iOS: DarwinNotificationDetails(
|
||||
presentAlert: true,
|
||||
presentBadge: true,
|
||||
presentSound: true,
|
||||
interruptionLevel: InterruptionLevel.timeSensitive,
|
||||
),
|
||||
);
|
||||
|
||||
await _notifications.show(
|
||||
id,
|
||||
title,
|
||||
body,
|
||||
details,
|
||||
);
|
||||
}
|
||||
|
||||
// Повторяющееся уведомление (ежедневное)
|
||||
Future<void> scheduleDailyNotification({
|
||||
required int id,
|
||||
required String title,
|
||||
required String body,
|
||||
required Time scheduledTime,
|
||||
String? payload,
|
||||
}) async {
|
||||
if (!_isInitialized) await initialize();
|
||||
|
||||
const details = NotificationDetails(
|
||||
android: AndroidNotificationDetails(
|
||||
'daily_reminders',
|
||||
'Ежедневные напоминания',
|
||||
channelDescription: 'Ежедневные уведомления',
|
||||
importance: Importance.defaultImportance,
|
||||
priority: Priority.defaultPriority,
|
||||
icon: '@mipmap/ic_launcher',
|
||||
),
|
||||
iOS: DarwinNotificationDetails(
|
||||
presentAlert: true,
|
||||
presentBadge: true,
|
||||
presentSound: true,
|
||||
),
|
||||
);
|
||||
|
||||
await _notifications.zonedSchedule(
|
||||
id,
|
||||
title,
|
||||
body,
|
||||
_nextInstanceOfTime(scheduledTime),
|
||||
details,
|
||||
androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle,
|
||||
uiLocalNotificationDateInterpretation:
|
||||
UILocalNotificationDateInterpretation.absoluteTime,
|
||||
matchDateTimeComponents: DateTimeComponents.time,
|
||||
payload: payload,
|
||||
);
|
||||
}
|
||||
|
||||
tz.TZDateTime _nextInstanceOfTime(Time scheduledTime) {
|
||||
final now = tz.TZDateTime.now(tz.local);
|
||||
var scheduled = tz.TZDateTime(
|
||||
tz.local,
|
||||
now.year,
|
||||
now.month,
|
||||
now.day,
|
||||
scheduledTime.hour,
|
||||
scheduledTime.minute,
|
||||
);
|
||||
|
||||
if (scheduled.isBefore(now)) {
|
||||
scheduled = scheduled.add(const Duration(days: 1));
|
||||
}
|
||||
|
||||
return scheduled;
|
||||
}
|
||||
}
|
||||
|
||||
final notificationService = NotificationService();
|
||||
@@ -11,6 +11,7 @@ import '../screens/ai_test_screen.dart';
|
||||
import '../screens/admin/admin_screen.dart';
|
||||
import '../screens/admin/team_management_screen.dart';
|
||||
import '../screens/admin/projects_management_screen.dart';
|
||||
import '../screens/admin/notifications_settings_screen.dart';
|
||||
import '../providers/auth_provider.dart';
|
||||
|
||||
// Глобальный ключ для навигации
|
||||
@@ -96,6 +97,12 @@ class AppRouter {
|
||||
name: 'admin-projects',
|
||||
builder: (context, state) => const ProjectsManagementScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: 'notifications',
|
||||
name: 'admin-notifications',
|
||||
builder: (context, state) =>
|
||||
const NotificationsSettingsScreen(),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
||||
import 'core/firebase_config.dart';
|
||||
import 'core/local_storage_service.dart';
|
||||
import 'core/notification_service.dart';
|
||||
import 'core/theme.dart';
|
||||
import 'core/router.dart';
|
||||
|
||||
@@ -18,6 +19,9 @@ void main() async {
|
||||
// Инициализация локального хранилища
|
||||
await localStorageService.initialize();
|
||||
|
||||
// Инициализация уведомлений
|
||||
await notificationService.initialize();
|
||||
|
||||
runApp(
|
||||
const ProviderScope(
|
||||
child: NanaxeCrmApp(),
|
||||
|
||||
@@ -5,6 +5,7 @@ import '../models/project_model.dart';
|
||||
import '../core/firestore_service.dart';
|
||||
import '../core/local_storage_service.dart';
|
||||
import '../core/deepseek_service.dart';
|
||||
import '../core/notification_service.dart';
|
||||
|
||||
// Provider для текущего пользователя (заглушка пока нет auth)
|
||||
final currentUserIdProvider = Provider<String?>((ref) {
|
||||
@@ -100,6 +101,7 @@ class TasksNotifier extends StateNotifier<AsyncValue<List<TaskModel>>> {
|
||||
DateTime? dueDate,
|
||||
int estimatedHours = 0,
|
||||
String? assigneeId,
|
||||
bool sendNotification = true,
|
||||
}) async {
|
||||
try {
|
||||
final currentUserId = ref.read(currentUserIdProvider);
|
||||
@@ -129,6 +131,19 @@ class TasksNotifier extends StateNotifier<AsyncValue<List<TaskModel>>> {
|
||||
print('Firestore save failed, saved locally only: $e');
|
||||
}
|
||||
|
||||
// Отправляем уведомление
|
||||
if (sendNotification) {
|
||||
await notificationService.showNewTaskNotification(
|
||||
taskTitle: title,
|
||||
taskId: task.id,
|
||||
);
|
||||
}
|
||||
|
||||
// Планируем напоминание о дедлайне
|
||||
if (dueDate != null) {
|
||||
await scheduleDeadlineReminder(task);
|
||||
}
|
||||
|
||||
// Обновляем состояние
|
||||
final currentTasks = state.value ?? [];
|
||||
state = AsyncValue.data([...currentTasks, task]);
|
||||
@@ -297,4 +312,46 @@ class TasksNotifier extends StateNotifier<AsyncValue<List<TaskModel>>> {
|
||||
status: TaskStatus.todo,
|
||||
);
|
||||
}
|
||||
|
||||
// Планирование напоминания о дедлайне
|
||||
Future<void> scheduleDeadlineReminder(TaskModel task) async {
|
||||
if (task.dueDate == null) return;
|
||||
|
||||
final now = DateTime.now();
|
||||
final dueDate = task.dueDate!;
|
||||
|
||||
// Напоминание за 24 часа
|
||||
if (dueDate.difference(now).inHours > 24) {
|
||||
final reminder24h = dueDate.subtract(const Duration(hours: 24));
|
||||
if (reminder24h.isAfter(now)) {
|
||||
await notificationService.scheduleNotification(
|
||||
id: '${task.id}_24h'.hashCode,
|
||||
title: '🔔 Напоминание о задаче',
|
||||
body: 'Задача "${task.title}" должна быть выполнена завтра',
|
||||
scheduledDate: reminder24h,
|
||||
payload: task.id,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Напоминание за 1 час
|
||||
if (dueDate.difference(now).inHours > 1) {
|
||||
final reminder1h = dueDate.subtract(const Duration(hours: 1));
|
||||
if (reminder1h.isAfter(now)) {
|
||||
await notificationService.scheduleNotification(
|
||||
id: '${task.id}_1h'.hashCode,
|
||||
title: '⏰ Скоро дедлайн!',
|
||||
body: 'Задача "${task.title}" должна быть выполнена через час',
|
||||
scheduledDate: reminder1h,
|
||||
payload: task.id,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Отмена напоминаний о задаче
|
||||
Future<void> cancelTaskReminders(String taskId) async {
|
||||
await notificationService.cancelNotification('${taskId}_24h'.hashCode);
|
||||
await notificationService.cancelNotification('${taskId}_1h'.hashCode);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../core/notification_service.dart';
|
||||
|
||||
class NotificationsSettingsScreen extends ConsumerStatefulWidget {
|
||||
const NotificationsSettingsScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<NotificationsSettingsScreen> createState() => _NotificationsSettingsScreenState();
|
||||
}
|
||||
|
||||
class _NotificationsSettingsScreenState extends ConsumerState<NotificationsSettingsScreen> {
|
||||
bool _taskNotifications = true;
|
||||
bool _deadlineReminders = true;
|
||||
bool _taskAssigned = true;
|
||||
bool _dailySummary = false;
|
||||
Time _dailySummaryTime = const Time(9, 0);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Уведомления'),
|
||||
),
|
||||
body: ListView(
|
||||
children: [
|
||||
const ListTile(
|
||||
title: Text('Типы уведомлений'),
|
||||
subtitle: Text('Настройте какие уведомления показывать'),
|
||||
),
|
||||
SwitchListTile(
|
||||
title: const Text('Задачи'),
|
||||
subtitle: const Text('Уведомления о создании и изменении задач'),
|
||||
value: _taskNotifications,
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_taskNotifications = value;
|
||||
});
|
||||
// TODO: Сохранить в настройки
|
||||
},
|
||||
),
|
||||
SwitchListTile(
|
||||
title: const Text('Напоминания о дедлайнах'),
|
||||
subtitle: const Text('За 24 часа и за 1 час до дедлайна'),
|
||||
value: _deadlineReminders,
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_deadlineReminders = value;
|
||||
});
|
||||
// TODO: Сохранить в настройки
|
||||
},
|
||||
),
|
||||
SwitchListTile(
|
||||
title: const Text('Назначение задач'),
|
||||
subtitle: const Text('Когда вас назначают исполнителем'),
|
||||
value: _taskAssigned,
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_taskAssigned = value;
|
||||
});
|
||||
// TODO: Сохранить в настройки
|
||||
},
|
||||
),
|
||||
const Divider(),
|
||||
SwitchListTile(
|
||||
title: const Text('Ежедневная сводка'),
|
||||
subtitle: const Text('Краткий отчёт о задачах на день'),
|
||||
value: _dailySummary,
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_dailySummary = value;
|
||||
if (value) {
|
||||
_scheduleDailySummary();
|
||||
}
|
||||
});
|
||||
// TODO: Сохранить в настройки
|
||||
},
|
||||
),
|
||||
if (_dailySummary)
|
||||
ListTile(
|
||||
title: const Text('Время сводки'),
|
||||
subtitle: Text('${_dailySummaryTime.hour.toString().padLeft(2, '0')}:${_dailySummaryTime.minute.toString().padLeft(2, '0')}'),
|
||||
trailing: const Icon(Icons.access_time),
|
||||
onTap: () => _selectDailySummaryTime(),
|
||||
),
|
||||
const Divider(),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.test_build, color: Colors.blue),
|
||||
title: const Text('Тестовое уведомление'),
|
||||
subtitle: const Text('Проверьте работу уведомлений'),
|
||||
onTap: _showTestNotification,
|
||||
),
|
||||
const Padding(
|
||||
padding: EdgeInsets.all(16),
|
||||
child: Text(
|
||||
'Уведомления будут приходить даже когда приложение закрыто. Убедитесь, что разрешения на уведомления включены в настройках устройства.',
|
||||
style: TextStyle(color: Colors.grey, fontSize: 12),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _selectDailySummaryTime() async {
|
||||
final TimeOfDay? picked = await showTimePicker(
|
||||
context: context,
|
||||
initialTime: TimeOfDay.fromDateTime(
|
||||
DateTime.now().copyWith(
|
||||
hour: _dailySummaryTime.hour,
|
||||
minute: _dailySummaryTime.minute,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
if (picked != null) {
|
||||
setState(() {
|
||||
_dailySummaryTime = Time(picked.hour, picked.minute);
|
||||
});
|
||||
_scheduleDailySummary();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _scheduleDailySummary() async {
|
||||
if (_dailySummary) {
|
||||
await notificationService.scheduleDailyNotification(
|
||||
id: 'daily_summary'.hashCode,
|
||||
title: '📊 Ежедневная сводка',
|
||||
body: 'Ваши задачи на сегодня',
|
||||
scheduledTime: _dailySummaryTime,
|
||||
);
|
||||
} else {
|
||||
await notificationService.cancelNotification('daily_summary'.hashCode);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _showTestNotification() async {
|
||||
await notificationService.showNotification(
|
||||
id: DateTime.now().millisecondsSinceEpoch,
|
||||
title: '🔔 Тестовое уведомление',
|
||||
body: 'Если вы видите это - уведомления работают!',
|
||||
);
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('✅ Уведомление отправлено')),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user