Flutter로 멋진 노트 앱을 만들어보자 ⑥ - 노트 알림 기능 완성하기
지난 글에서는 알림의 데이터 모델과 기본 로직을 구현했습니다. 노트 알림 로직 구현하기
이번 글에서는 알림 기능 구현을 최종 완성해 보겠습니다.
이전에 구현해 둔 알림 UI에 알림 로직을 적용하고, 알림 설정 업데이트, 여러개의 알림을 한 번에 켜고 끌 수 있는 다중 선택 모드, 그리고 알림 클릭 시 노트 상세 화면으로 이동하는 기능까지 구현하겠습니다.
최종 결과 이미지는 다음과 같습니다.
구현 순서는 다음 목차 순서대로 진행합니다.
목차
- 알림 로직 적용
- 알림 설정 변경 및 업데이트
- 다중 선택 모드 구현
- 알림 클릭 시 노트 상세 화면으로 이동
1. 알림 로직 적용
먼저, 노트 알림 화면(NoteAlarmView)에서 각 개별 알림에 대한 UI와 데이터는 알림 카드(NotificationCard) 컴포넌트로 분리하겠습니다.
알림 카드에서는 알림 정보를 표시하고 설정, 삭제, 활성화 상태를 관리할 수 있도록 수정합니다.
class NotificationCard extends StatelessWidget {
final NoteModel note;
final NoteNotification notification;
final String Function(BuildContext, NoteNotification) getNotificationDescription;
@override
Widget build(BuildContext context) {
return Container(
margin: const EdgeInsets.only(bottom: 16),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: Theme.of(context).colorScheme.shadow.withOpacity(0.08),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: ListTile(
leading: Icon(
notification.type == NotificationType.scheduled ? Icons.event : Icons.repeat,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
title: Text(
getNotificationDescription(context, notification),
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
Switch(
value: notification.isActive,
onChanged: (value) {
// 알림 활성화/비활성화 로직
},
),
IconButton(
icon: const Icon(Icons.delete_outline),
onPressed: () {
showDialog(
context: context,
builder: (context) => DeleteNotificationDialog(
onDelete: () async {
// 알림 삭제 로직
},
),
);
},
),
],
),
),
);
}
}
아직 onChanged 와 onDelete 가 비어있는데요.
onChanged 는 알림 활성화/비활성화에 대한 토글 기능을 합니다. 이를 구현하기 위해 NotificationService 에 toggleNotification 메서드를 추가합니다.
Future<void> toggleNotification(
String noteId,
NoteNotification notification,
) async {
final now = DateTime.now();
// 1. 예약된 알림이고 시간이 지난 경우
if (notification.type == NotificationType.scheduled &&
notification.scheduledDateTime != null &&
notification.scheduledDateTime!.isBefore(now)) {
// 이미 비활성화 상태면 early return
if (!notification.isActive) {
return;
}
// 지난 알림 비활성화 처리
await _notifications.cancel(notification.id.hashCode);
notification.isActive = false;
_updateNotificationInMemory(notification);
await _saveNotificationToLocal(notification);
notifyListeners();
return;
}
// 2. 활성 상태에 따른 기존 로직 처리
notification.isActive = !notification.isActive;
if (notification.isActive) {
await scheduleNotification(notification);
} else {
await cancelNotification(notification);
}
// 메모리와 로컬 스토리지 업데이트(세부 구현 생략)
_updateNotificationInMemory(notification);
await _saveNotificationToLocal(notification);
notifyListeners();
}
toggleNotification 를 구현했다면 다시 알림 카드 컴포넌트로 돌아와 onChanged 에 넣어줍니다.
onChanged: (value) async {
await notificationService.toggleNotification(
note.id,
notification,
note.content,
);
},
다음으로 onDelete 에 붙여줄 알림 삭제 다이얼로그를 추가하겠습니다.
class DeleteNotificationDialog extends StatelessWidget {
final Function() onDelete;
const DeleteNotificationDialog({super.key, required this.onDelete});
@override
Widget build(BuildContext context) {
return AlertDialog(
title: const Text('알림 삭제'),
content: const Text('이 알림을 삭제하시겠습니까?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('취소'),
),
TextButton(
onPressed: () {
Navigator.pop(context);
onDelete();
},
child: const Text('삭제', style: TextStyle(color: Colors.red)),
),
],
);
}
}
노트 삭제 다이얼로그와 마찬가지로 사용자가 알림을 실수로 삭제하지 않도록 사용자 확인을 하는 다이얼로그입니다.
구현한 다이얼로그를 아까 비워져 있던 알림 카드 컴포넌트의 onDelete 에 넣어줍니다.
이제 활성화/비활성화 토글 버튼과 삭제 버튼이 동작하게 됩니다.
2. 알림 설정 변경 및 업데이트
알림을 등록한 후에 날짜나 시간을 변경하고 싶을 때가 있습니다. 혹은 일회성 알림을 반복 알림으로 바꾸고 싶을 수도 있죠. 이를 위해서 기존 알림 설정을 변경하고 업데이트할 수 있는 기능이 필요했습니다.
알림 수정은 기존 알림 데이터를 복사하여 새로운 값을 적용하는 방식으로 구현합니다. NotificationService 에 updateNotification 메서드를 추가합니다.
Future<void> updateNotification(
String noteId,
NoteNotification notification,
String content,
) async {
// 기존 알림 취소
await cancelNotification(notification);
// 새로운 알림 설정
if (notification.isActive) {
await scheduleNotification(notification, content);
}
// 메모리와 저장소 업데이트
_updateNotificationInMemory(notification);
await _saveNotificationToLocal(notification, content);
notifyListeners();
}
updateNotification 를 호출하는 콜백 함수를 정의합니다.
Future<void> _updateNotification(
NotificationService notificationService,
NotificationType type,
DateTime? scheduledDateTime,
List<int>? weekdays,
TimeOfDay? timeOfDay,
) async {
final updatedNotification = notification.copyWith(
type: type,
scheduledDateTime: scheduledDateTime,
weekdays: weekdays,
timeOfDay: timeOfDay,
);
await notificationService.updateNotification(note.id, updatedNotification, note.content);
}
이 함수를 NotificationSettingsDialog 의 onScheduleNotification 파라미터에 전달합니다.
이렇게 하면 다이얼로그에서 설정 완료를 눌렀을 때 해당 콜백 함수가 실행되어 알림을 업데이트하게 됩니다.
마지막으로 알림 카드에서 알림 설정 다이얼로그를 띄우기 위한 함수를 추가합니다.
// 알림 설정 다이얼로그 표시
void _showNotificationSettingsDialog(
BuildContext context,
NotificationService notificationService,
) {
showDialog<void>(
context: context,
builder: (dialogContext) => NotificationSettingsDialog(
noteId: note.id,
initialNotification: notification,
onScheduleNotification: (dateTime) {
onNotificationUpdate?.call(
NotificationType.scheduled,
scheduledDateTime: dateTime,
timeOfDay: TimeOfDay.fromDateTime(dateTime),
);
},
onRecurringNotification: (weekdays, time) {
onNotificationUpdate?.call(
NotificationType.recurring,
weekdays: weekdays,
timeOfDay: time,
);
},
),
);
}
_showNotificationSettingsDialog 함수를 알림 카드 컴포넌트의 날짜 텍스트를 눌렀을 때 동작하도록 적용하면 알림 설정 다이얼로그를 띄울 수 있습니다.
알림 설정을 변경하고 업데이트하는 모습입니다.
3. 다중 선택 모드 구현
여러 알림 들을 한 번에 활성화시키거나 비활성화시키고 싶을 때가 있습니다. 혹은 알림을 한 번에 정리(삭제)하고 싶을 수도 있죠.
이를 위해서 여러 개의 알림을 선택할 수 있는 다중 선택 모드를 구현합니다.
먼저, 노트 알림 화면(NoteAlarmView)에서 다중 선택 모드의 상태를 관리할 수 있어야 합니다.
void _toggleSelectionMode() {
setState(() {
_isSelectionMode = !_isSelectionMode;
if (!_isSelectionMode) {
_selectedNotifications.clear(); // 알림 목록 초기화
}
});
}
void _toggleNotificationSelection(String notificationId) {
setState(() {
if (_selectedNotifications.contains(notificationId)) {
_selectedNotifications.remove(notificationId);
} else {
_selectedNotifications.add(notificationId);
}
if (_selectedNotifications.isEmpty) {
_isSelectionMode = false;
}
});
}
_isSelectionMode 와 _selectedNotifications 를 통해 다중 선택 모드 상태와 선택된 알림의 아이디를 관리합니다. 이후에 다중 선택 모드에서 빠져나올 때는 선택된 알림 목록 초기화합니다.
다중 선택 모드에서는 상단 앱바에 선택된 알림 개수를 표시하고 "켜기", "끄기", "삭제" 같은 액션 버튼이 나타나도록 하겠습니다.
AppBar(
title: AnimatedSwitcher(
duration: const Duration(milliseconds: 200),
child: Text(
_isSelectionMode ? '${_selectedNotifications.length}개 선택됨' : '노트 알림',
key: ValueKey<bool>(_isSelectionMode),
),
),
),
저는 다중 선택 모드로 전환되는 과정이 자연스러워 보이도록 애니메이션을 적용했습니다.
액션 버튼들은 다음처럼 구성합니다.
TextButton.icon(
onPressed: _isSelectionMode
? () => _handleBulkAction(
context, notificationService, noteService, BulkAction.activate)
: null,
icon: const Icon(Icons.notifications_active),
label: const Text('켜기'),
),
TextButton.icon(
onPressed: _isSelectionMode
? () => _handleBulkAction(
context, notificationService, noteService, BulkAction.deactivate)
: null,
icon: const Icon(Icons.notifications_off),
label: const Text('끄기'),
),
TextButton.icon(
onPressed: _isSelectionMode
? () => _handleBulkAction(
context, notificationService, noteService, BulkAction.delete)
: null,
icon: const Icon(Icons.delete_outline),
label: const Text('삭제'),
style: TextButton.styleFrom(
foregroundColor: Theme.of(context).colorScheme.error, // 에러 색상 적용
),
),
각 액션 버튼이 실제로 동작하도록 _handleBulkAction 함수를 추가합니다. 세부 구현은 notificationService 을 이용하여 구현해 주세요. 이 글에서는 생략하겠습니다.
Future<void> _handleBulkAction(
BuildContext context,
NotificationService notificationService,
NoteService noteService,
BulkAction action,
) async {
switch (action) {
case BulkAction.activate: ... // 일괄 활성화 구현
case BulkAction.deactivate: ... // 일괄 비활성화 구현
case BulkAction.delete: ... // 일괄 삭제 구현
}
_toggleSelectionMode(); // 모드 종료
}
이제 알림 카드(NotificationCard)로 넘어가 필요한 파라미터를 추가합니다.
NotificationCard(
isSelectable: _isSelectionMode,
isSelected: _selectedNotifications.contains(notification.id),
onLongPress: () {
if (!_isSelectionMode) {
_toggleSelectionMode();
_toggleNotificationSelection(notification.id);
}
},
onSelect: () => _toggleNotificationSelection(notification.id),
);
각 파라미터를 설명하자면 다음과 같습니다.
- isSelectable 는 다중 선택 모드 유무를 뜻합니다.
- isSelected 는 해당 알림 카드가 선택된 상태인지를 나타냅니다.
- onLongPress 는 알림 카드를 길게 눌렀을 때 동작하는 콜백 함수를 받습니다. 다중 선택 모드가 아닌 경우에 다중 선택 모드로 전환하고 해당 알림 카드를 선택합니다.
- onSelect 는 해당 알림 카드를 선택/해제하는 콜백 함수를 받습니다.
마지막으로 다중 선택 모드라면 알림 카드 컴포넌트에 체크 박스 UI가 생기도록 추가합니다.
leading: isSelectable
? Checkbox(
value: isSelected,
onChanged: (_) => onSelect?.call(),
)
: Icon(
notification.type == NotificationType.scheduled
? Icons.event
: Icons.repeat,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
체크박스의 value 는 isSelected 로 지정하고 onChanged 에 onSelect 콜백 함수로 지정합니다. 이렇게 하면 사용자가 체크박스를 클릭하여 알림 선택 유/무를 변경할 수 있습니다.
처음에는 알림이 모두 활성화되어있었지만, 다중 선택 모드에서 첫 번째와 세 번째 알림 카드를 선택하고 '끄기' 버튼을 누른 모습입니다.
4. 알림 클릭 시 노트 상세 화면으로 이동
노트 앱이 보내는 알림은 특정 노트를 리마인드 하기 위한 알림입니다. 그러므로 사용자가 알림을 받고 눌렀을 때 해당 하는 노트의 상세 화면으로 이동시켜야 합니다.
먼저 main.dart 에 navigatorKey 를 추가합니다.
// 전역 navigator key 추가
final GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>();
// GoRouter 설정에 navigatorKey 추가
static final _router = GoRouter(
navigatorKey: navigatorKey,
initialLocation: '/noteList',
routes: [
ShellRoute(
builder: (context, state, child) => HomeScreen(key: homeScreenKey, child: child),
routes: [
// ...
],
),
],
);
Flutter 앱 내부의 라우팅은 기본적으로 BuildContext를 기반으로 작동합니다. 하지만 알림 클릭 이벤트는 앱과 독립적으로 발생하기 때문에 GoRouter 를 앱 전체에서 사용할 수 있는 navigatorKey 를 추가해야 합니다.
NotificationService 로 넘어가 알림 클릭 이벤트를 처리하겠습니다.
flutter_local_notifications 패키지는 onDidReceiveNotificationResponse 와 onDidReceiveBackgroundNotificationResponse 를 통해 알림 클릭 이벤트를 처리합니다.
@pragma('vm:entry-point') // 백그라운드 알림 클릭 시 호출
void notificationTapBackground(NotificationResponse details) {
if (details.payload != null) {
final noteId = details.payload;
final context = navigatorKey.currentContext;
if (context != null) {
GoRouter.of(context).go('/noteDetail?id=$noteId');
}
}
}
class NotificationService extends ChangeNotifier {
// 알림 초기화
Future<void> initialize() async {
const initSettings = InitializationSettings(
android: AndroidInitializationSettings('@mipmap/ic_launcher'),
iOS: DarwinInitializationSettings(),
);
await _notifications.initialize(
initSettings,
onDidReceiveNotificationResponse: (NotificationResponse details) {
if (details.payload != null) {
final noteId = details.payload;
final context = navigatorKey.currentContext;
if (context != null) {
GoRouter.of(context).go('/noteDetail?id=$noteId');
}
}
},
onDidReceiveBackgroundNotificationResponse: notificationTapBackground,
);
}
// 알림 생성 시 payload로 noteId 전달
Future<void> scheduleNotification(NoteNotification notification, String content) async {
await _notifications.zonedSchedule(
notification.id.hashCode,
'띵노트 알림',
content,
_convertToTZDateTime(notification.scheduledDateTime!),
_notificationDetails,
androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle,
uiLocalNotificationDateInterpretation: UILocalNotificationDateInterpretation.absoluteTime,
payload: notification.noteId, // 알림 클릭 시 전달할 노트 ID
);
}
}
처음 알림을 생성할 때 noteId 를 payload 로 전달하도록 추가합니다.
이제 사용자가 알림을 클릭해 이벤트가 발생하면 payload 로 부터 noteId 를 가져와 /noteDetail 로 라우팅 합니다.
알림을 클릭했을 때 노트 상세 화면으로 이동한 모습입니다.
참고로, 안드로이드의 경우 AndroidManifest.xml 파일에 다음 설정을 추가해야 합니다.
<intent-filter>
<!-- 알림 클릭 시 앱 실행 -->
<action android:name="FLUTTER_NOTIFICATION_CLICK" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
이 intent-filter는 flutter_local_notifications에서 알림을 클릭했을 때 앱으로 이벤트를 전달하는 데 사용됩니다.
iOS의 경우에도 AppDelegate.swift 파일에 다음 코드를 추가하여 알림 이벤트를 처리할 수 있습니다.
UNUserNotificationCenter.current().delegate = self
델리게이트 대한 자세한 내용은 Flutter 앱에서 iOS 포그라운드(실행 중) 알림 동작 설정하기 글을 참고해 주세요.
마무리하며
알림 로직 적용부터 알림 설정 변경, 다중 선택 모드, 알림 클릭 시 노트 상세 화면으로의 이동까지 알림 기능을 모두 구현해 보았습니다.
사실 일부 UI를 변경하거나 디테일한 로직이 더 있으나 이 글에서는 다루지 않았습니다. 만약 모든 과정과 코드를 담는다면 글이 너무 길어질뿐더러 지엽적인 내용이 될 거라 생각합니다. 이 글은 구현 과정의 큰 흐름을 보는데 활용해 주세요.
다음 글에서는 작성한 노트를 파일로 내보내고 다시 가져오는 노트 백업 기능을 다루어보겠습니다.
[Flutter로 멋진 노트 앱을 만들어보자] 시리즈는 직접 독학으로 하나씩 만들어나가는 과정이므로 틀리거나 부족한 내용이 있을 수 있습니다. 조언과 피드백을 댓글로 남겨주시면 적극 반영하겠습니다. 감사합니다.