Add Local Notifications with deadline reminders

This commit is contained in:
2026-03-27 19:47:27 +05:00
parent 88db5daa6b
commit 789ecd9d3a
5 changed files with 583 additions and 0 deletions
+365
View File
@@ -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();
+7
View File
@@ -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(),
),
],
),
],
+4
View File
@@ -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(),
+57
View File
@@ -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('✅ Уведомление отправлено')),
);
}
}
}