From 789ecd9d3aacc5814e5938a025e19fdb439ff409 Mon Sep 17 00:00:00 2001 From: AlexIves16 Date: Fri, 27 Mar 2026 19:47:27 +0500 Subject: [PATCH] Add Local Notifications with deadline reminders --- lib/core/notification_service.dart | 365 ++++++++++++++++++ lib/core/router.dart | 7 + lib/main.dart | 4 + lib/providers/task_provider.dart | 57 +++ .../admin/notifications_settings_screen.dart | 150 +++++++ 5 files changed, 583 insertions(+) create mode 100644 lib/core/notification_service.dart create mode 100644 lib/screens/admin/notifications_settings_screen.dart diff --git a/lib/core/notification_service.dart b/lib/core/notification_service.dart new file mode 100644 index 0000000..aa5cb29 --- /dev/null +++ b/lib/core/notification_service.dart @@ -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 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 _requestPermissions() async { + // Android 13+ требует запрос разрешений + final androidPlugin = _notifications + .resolvePlatformSpecificImplementation(); + + if (androidPlugin != null) { + await androidPlugin.requestNotificationsPermission(); + } + + // iOS + final iosPlugin = _notifications + .resolvePlatformSpecificImplementation(); + + 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 _createTaskChannel() async { + const channel = AndroidNotificationChannel( + 'tasks_channel', + 'Задачи', + description: 'Уведомления о задачах и дедлайнах', + importance: Importance.high, + enableVibration: true, + playSound: true, + ); + + await _notifications + .resolvePlatformSpecificImplementation() + ?.createNotificationChannel(channel); + } + + // Создание канала для будильников + Future _createAlarmChannel() async { + const channel = AndroidNotificationChannel( + 'alarms_channel', + 'Будильники', + description: 'Будильники и напоминания', + importance: Importance.max, + enableVibration: true, + playSound: true, + ); + + await _notifications + .resolvePlatformSpecificImplementation() + ?.createNotificationChannel(channel); + } + + // Показ немедленного уведомления + Future 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 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 cancelNotification(int id) async { + await _notifications.cancel(id); + } + + // Отмена всех уведомлений + Future cancelAllNotifications() async { + await _notifications.cancelAll(); + } + + // Уведомление о дедлайне задачи + Future 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 showNewTaskNotification({ + required String taskTitle, + String? taskId, + }) async { + await showNotification( + id: taskId.hashCode, + title: '📋 Новая задача', + body: 'Задача "$taskTitle" создана', + payload: taskId, + ); + } + + // Уведомление о назначении задачи + Future showTaskAssignedNotification({ + required String taskTitle, + required String assignedBy, + String? taskId, + }) async { + await showNotification( + id: taskId.hashCode, + title: '🎯 Вам назначена задача', + body: '$assignedBy назначил вас на задачу "$taskTitle"', + payload: taskId, + ); + } + + // Будильник + Future 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 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(); diff --git a/lib/core/router.dart b/lib/core/router.dart index 940396d..4040efe 100644 --- a/lib/core/router.dart +++ b/lib/core/router.dart @@ -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(), + ), ], ), ], diff --git a/lib/main.dart b/lib/main.dart index d96f877..cef558a 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -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(), diff --git a/lib/providers/task_provider.dart b/lib/providers/task_provider.dart index 958667d..42a7ff0 100644 --- a/lib/providers/task_provider.dart +++ b/lib/providers/task_provider.dart @@ -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((ref) { @@ -100,6 +101,7 @@ class TasksNotifier extends StateNotifier>> { 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>> { 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>> { status: TaskStatus.todo, ); } + + // Планирование напоминания о дедлайне + Future 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 cancelTaskReminders(String taskId) async { + await notificationService.cancelNotification('${taskId}_24h'.hashCode); + await notificationService.cancelNotification('${taskId}_1h'.hashCode); + } } diff --git a/lib/screens/admin/notifications_settings_screen.dart b/lib/screens/admin/notifications_settings_screen.dart new file mode 100644 index 0000000..d3ca8db --- /dev/null +++ b/lib/screens/admin/notifications_settings_screen.dart @@ -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 createState() => _NotificationsSettingsScreenState(); +} + +class _NotificationsSettingsScreenState extends ConsumerState { + 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 _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 _scheduleDailySummary() async { + if (_dailySummary) { + await notificationService.scheduleDailyNotification( + id: 'daily_summary'.hashCode, + title: '📊 Ежедневная сводка', + body: 'Ваши задачи на сегодня', + scheduledTime: _dailySummaryTime, + ); + } else { + await notificationService.cancelNotification('daily_summary'.hashCode); + } + } + + Future _showTestNotification() async { + await notificationService.showNotification( + id: DateTime.now().millisecondsSinceEpoch, + title: '🔔 Тестовое уведомление', + body: 'Если вы видите это - уведомления работают!', + ); + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('✅ Уведомление отправлено')), + ); + } + } +}