플러터(Flutter) 로컬 푸시 알림 보내기

안녕하세요. 이번 시간에는 플러터로 'flutter local notifications' 패키지를 사용해서 로컬 푸시 알림을 보내는 안드로이드 앱을 만들어 볼게요. 로컬 푸시는 앱에서 발생시키는 알림 메시지로 서버와 통신하지 않고 로컬에서 처리되는 것을 말해요. 즉, 서버에서 푸시 메시지를 발송하는 것이 아니라 앱 내부에서 직접 푸시 메시지를 생성하고 전송하는 방식이에요. 그렇기 때문에 인터넷 열결 없이도 알림을 받을 수 있어요.

 

이번에 사용할 flutter_local_notifications는 다양한 설정을 통해 사용자가 원하는 방식으로 푸시 알림을 구현할 수 있어요. 예를 들어 사용자가 알림을 클릭할 때 실행될 앱의 화면을 지정하거나, 알림 사운드, 진동 패턴, 이미지 또는 앱 아이콘 같은 추가 정보를 포함시켜서 보여줄 수 있죠.

 

그럼 이제 개발을 시작하기 전 준비작업을 먼저 해볼게요. pubspec.yaml에 아래와 같이 두 가지 패키지를 추가해 주세요. permission_handler는 앱에서 사용자에게 권한을 요청하고, 권한 상태 확인 및 권한 변경을 처리하는 데 사용합니다.

dependencies:
  flutter:
    sdk: flutter

  flutter_local_notifications: ^13.0.0
  permission_handler: ^8.3.0

 

AndroidManifest.xml 파일 태그 안에 앱에 필요한 권한 두 가지를 추가합니다.

<manifest
   <uses-permission android:name="android.permission.VIBRATE"/>
   <uses-permission android:name="android.permission.WAKE_LOCK"/>
   ...기존코드
</manifest>

VIBRATE: 알림이 발생할 때 진동 기능을 사용할 수 있도록 허용합니다.

WAKE_LOCK: 알림이 발생했을 때 기기가 슬립 상태에서 깨어날 수 있도록 허용합니다.

 

마지막으로 예약된 알림을 처리하기 위해 <receiver> 태그를 사용하여 <manifest> 태그 안에 아래와 같이 추가해 주시면 됩니다.

<application
   ...기존 코드
   <receiver android:name="com.dexterous.flutterlocalnotifications.ScheduledNotificationReceiver" />
</application>

 

이번 예제는 알림 관련 작업을 수행하는 notification_servic.dart와 앱의 화면을 구성하는 home_screen.dart로 분리하여 개발할게요. 먼저 main.dart를 만들겠습니다. main()에서는 초기화만 수행하도록 합니다.

import 'package:flutter/material.dart';
import 'home_screen.dart';
import 'notification_service.dart';
void main() async {
  // 앱 실행 전에 NotificationService 인스턴스 생성
  final notificationService = NotificationService();
  // Flutter 엔진 초기화
  WidgetsFlutterBinding.ensureInitialized();
  // 로컬 푸시 알림 초기화
  await notificationService.init();
  runApp(MyApp());
}
class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return const MaterialApp(
      home: HomeScreen(), 
    );
  }
}

WidgetsFlutterBinding.ensureInitialized는 앱이 시작되기 전에 특정 플러그인이나 작업이 먼저 초기화되어야 할 때 사용돼요. 이번 예제는 비동기인 await으로 초기화하기 때문에 필수로 수행해야 합니다. Flutter 프레임워크와 플러그인 사이의 바인딩이 초기화되고, 이후 초기화 작업이 정상적으로 수행되는 것을 보장하기 때문이죠.

 

다음은 화면을 만들겠습니다. 화면은 시작/정지 버튼과 초기화 버튼이 있고, 알림 시간을 입력하는 텍스트 필드와 1초씩 증가하는 타이머를 만들어줄게요. 타이머가 입력한 시간에 도달하면 알림을 보냅니다.

import 'dart:async';
import 'package:flutter/material.dart';
import 'package:permission_handler/permission_handler.dart';
import 'notification_service.dart';

class HomeScreen extends StatefulWidget {
  const HomeScreen({super.key});

  @override
  HomeScreenState createState() => HomeScreenState();
}

class HomeScreenState extends State<HomeScreen> {
  int _counter = 0; // _counter 변수를 0으로 초기화
  int _targetNumber = 10; // _targetNumber 변수를 10으로 초기화
  Timer? _timer; // 타이머를 선언

  @override
  void initState() {
    super.initState();
    _requestNotificationPermissions(); // 알림 권한 요청
  }

  void _requestNotificationPermissions() async {
    //알림 권한 요청
    final status = await NotificationService().requestNotificationPermissions();
    if (status.isDenied && context.mounted) {
      showDialog(
        // 알림 권한이 거부되었을 경우 다이얼로그 출력
        context: context,
        builder: (context) => AlertDialog(
          title: Text('알림 권한이 거부되었습니다.'),
          content: Text('알림을 받으려면 앱 설정에서 권한을 허용해야 합니다.'),
          actions: <Widget>[
            TextButton(
              child: Text('설정'), //다이얼로그 버튼의 죄측 텍스트
              onPressed: () {
                Navigator.of(context).pop();
                openAppSettings(); //설정 클릭시 권한설정 화면으로 이동
              },
            ),
            TextButton(
              child: Text('취소'), //다이얼로그 버튼의 우측 텍스트
              onPressed: () => Navigator.of(context).pop(), //다이얼로그 닫기
            ),
          ],
        ),
      );
    }
  }

  @override
  Widget build(BuildContext context) {
    //화면 구성
    return Scaffold(
      appBar: AppBar(title: const Text('쭈미로운 생활 푸시 알림 예제')),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Text('타이머: $_counter'),
            const SizedBox(height: 16),
            Row(
              mainAxisAlignment: MainAxisAlignment.center,
              children: [
                const Text('알림 시간 입력(초) : '),
                SizedBox(
                  width: 60,
                  child: TextField(
                    keyboardType: TextInputType.number,
                    onChanged: (value) {
                      setState(() {
                        _targetNumber = int.parse(value);
                      });
                    },
                  ),
                ),
              ],
            ),
            const SizedBox(height: 16),
            Row(
              mainAxisAlignment: MainAxisAlignment.center,
              children: [
                ElevatedButton(
                  onPressed: _resetCounter,
                  child: const Text('초기화'),
                ),
                const SizedBox(width: 16),
                ElevatedButton(
                  onPressed: _toggleTimer,
                  child: Text(_timer?.isActive == true ? '정지' : '시작'),
                ),
              ],
            ),
          ],
        ),
      ),
    );
  }

  void _resetCounter() {
    setState(() {
      _counter = 0; // _counter 변수를 0으로 초기화
    });
  }

  void _toggleTimer() {
    // 타이머 시작/정지 기능
    if (_timer?.isActive == true) {
      _stopTimer();
    } else {
      _startTimer();
    }
  }

  void _startTimer() {
    //타이머 시작
    _timer = Timer.periodic(const Duration(seconds: 1), (_) {
      setState(() {
        _counter++;
        if (_counter == _targetNumber) {
          NotificationService().showNotification(_targetNumber);
          _stopTimer();
        }
      });
    });
  }

  void _stopTimer() {
    //타이머 정지
    _timer?.cancel();
  }
}

_requestNotificationPermissions()

사용자에게 알림 권한을 요청하는 팝업이 표시돼요. 이때, 사용자가 권한 요청을 허용하거나 거부할 수 있으며, 그에 따라 PermissionStatus 값이 반환되는데, 권한을 허가하지 않으면 앱을 실행할 때마다 요청 팝업이 표시된답니다.

 

권한 허용 전 실행 화면 ▼

푸시알림 화면1푸시알림 화면2

 

이렇게 화면이 완성됐습니다! 권한 요청 팝업에서 설정을 클릭하면 앱 권한 정보 화면으로 넘어갑니다. 위와 같이 권한을 허용해야 푸시 알림을 받을 수 있어요. 이제 푸시 알림을 제어하는 클래스 notification_servic.dart를 만들겠습니다.

import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:permission_handler/permission_handler.dart';

class NotificationService {
  // 싱글톤 패턴을 사용하기 위한 private static 변수
  static final NotificationService _instance = NotificationService._();
  // NotificationService 인스턴스 반환
  factory NotificationService() {
    return _instance;
  }
  // private 생성자
  NotificationService._();
  // 로컬 푸시 알림을 사용하기 위한 플러그인 인스턴스 생성
  final FlutterLocalNotificationsPlugin flutterLocalNotificationsPlugin =
      FlutterLocalNotificationsPlugin();
  // 초기화 작업을 위한 메서드 정의
  Future<void> init() async {
    // 알림을 표시할 때 사용할 로고를 지정
    const AndroidInitializationSettings initializationSettingsAndroid =
        AndroidInitializationSettings('@mipmap/ic_launcher');
    // 안드로이드 플랫폼에서 사용할 초기화 설정
    const InitializationSettings initializationSettings =
        InitializationSettings(android: initializationSettingsAndroid);
    // 로컬 푸시 알림을 초기화
    await flutterLocalNotificationsPlugin.initialize(initializationSettings);
  }
  // 푸시 알림 생성
  Future<void> showNotification(int targetNumber) async {
    // 푸시 알림의 ID
    const int notificationId = 0;
    // 알림 채널 설정값 구성
    final AndroidNotificationDetails androidNotificationDetails =
        AndroidNotificationDetails(
      'counter_channel', // 알림 채널 ID
      'Counter Channel', // 알림 채널 이름
      channelDescription:
          'This channel is used for counter-related notifications',
      // 알림 채널 설명
      importance: Importance.high, // 알림 중요도
    );
    // 알림 상세 정보 설정
    final NotificationDetails notificationDetails =
        NotificationDetails(android: androidNotificationDetails);
    // 알림 보이기
    await flutterLocalNotificationsPlugin.show(
      notificationId, // 알림 ID
      '목표 도달', // 알림 제목
      '$targetNumber 회 눌렀습니다!', // 알림 메시지
      notificationDetails, // 알림 상세 정보
    );
  }
  // 푸시 알림 권한 요청
  Future<PermissionStatus> requestNotificationPermissions() async {
    final status = await Permission.notification.request();
    return status;
  }
}

factory 생성자

Dart의 factory 생성자는 플러터에서 싱글톤을 만들 때 사용하는 방법 중 하나예요. 인스턴스가 이미 존재하는 경우에는 이를 반환하고, 존재하지 않는 경우에는 새로운 인스턴스를 생성하게 돼요. 즉 한 개의 인스턴스만 사용하게 되는 거예요.

 

AndroidNotificationDetails()

  • 채널 설정 : 푸시 알림 그룹화와 관련이 있어요. 예를 들어, 채널 ID가 "my_messages"이고, 알림 제목이 "나의 메시지가 도착했습니다"인 두 개의 알림이 있다면, 시스템은 이 두 알림을 채널 ID로 구분해서 사용자가 알림을 그룹화된 형태로 확인할 수 있도록 하는 거죠.
  • importance : 알림 채널 중요도에 시스템이 알림의 우선순위를 결정하는 옵션이에요. high를 설정하면 사용자가 바로 알림을 확인할 수 있도록 즉각적으로 표시되지만, low로 설정하면 다른 알림에 비해 덜 중요하다고 판단하고 나중에 표시되거나 시스템에서 알림을 건너뛰기도 합니다.

 

requestNotificationPermissions()

알림 권한에 대해 권한을 요청해요. await 키워드를 사용해서 권한 요청이 완료될 때까지 대기하고 status 변수에 요청한 권한에 대한 상태를 받아옵니다.

 

타이머를 3초로 맞추고 앱이 실행 중일 때 푸시 알림과 백그라운드에서 실행 중일 때 푸시 알림이 각각 어떻게 오는지 각각 실행해 볼게요.

 

실행 화면 ▼

푸시알림 실행1푸시알림 실행2

좌측 : 앱이 실행 중일 때 푸시 알림

우측 : 백그라운드에서 실행 중일 때 푸시 알림

 

 

오늘은 이렇게 flutter_local_notifications 패키지를 사용해서 간단하게 구현해 봤는데요. 이 패키지는 앱 내에서 로컬 푸시 알림을 보내기는 유용하지만 앱이 완전히 종료되면 알림을 받을 수 없는 단점이 있어요. 물론 홈 화면으로 이동 후 다른 앱을 띄우거나 화면이 꺼지는 경우에는 알림이 온답니다. 그. 래. 서 다음 시간에는 이 단점을 해결해서 앱이 종료되어도 푸시 알림을 받을 수 있는 앱을 만들 예정입니다!  - 끝 -