feat: 초기 프로젝트 설정 및 LunchPick 앱 구현
LunchPick(오늘 뭐 먹Z?) Flutter 앱의 초기 구현입니다. 주요 기능: - 네이버 지도 연동 맛집 추가 - 랜덤 메뉴 추천 시스템 - 날씨 기반 거리 조정 - 방문 기록 관리 - Bluetooth 맛집 공유 - 다크모드 지원 기술 스택: - Flutter 3.8.1+ - Riverpod 상태 관리 - Hive 로컬 DB - Clean Architecture 🤖 Generated with Claude Code Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
284
lib/core/services/notification_service.dart
Normal file
284
lib/core/services/notification_service.dart
Normal file
@@ -0,0 +1,284 @@
|
||||
import 'dart:math';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||
import 'package:timezone/timezone.dart' as tz;
|
||||
import 'package:timezone/data/latest_all.dart' as tz;
|
||||
|
||||
/// 알림 서비스 싱글톤 클래스
|
||||
class NotificationService {
|
||||
// 싱글톤 인스턴스
|
||||
static final NotificationService _instance = NotificationService._internal();
|
||||
factory NotificationService() => _instance;
|
||||
NotificationService._internal();
|
||||
|
||||
// Flutter Local Notifications 플러그인
|
||||
final FlutterLocalNotificationsPlugin _notifications = FlutterLocalNotificationsPlugin();
|
||||
|
||||
// 알림 채널 정보
|
||||
static const String _channelId = 'lunchpick_visit_reminder';
|
||||
static const String _channelName = '방문 확인 알림';
|
||||
static const String _channelDescription = '점심 식사 후 방문을 확인하는 알림입니다.';
|
||||
|
||||
// 알림 ID (방문 확인용)
|
||||
static const int _visitReminderNotificationId = 1;
|
||||
|
||||
/// 알림 서비스 초기화
|
||||
Future<bool> initialize() async {
|
||||
// 시간대 초기화
|
||||
tz.initializeTimeZones();
|
||||
|
||||
// Android 초기화 설정
|
||||
const androidInitSettings = AndroidInitializationSettings('@mipmap/ic_launcher');
|
||||
|
||||
// iOS 초기화 설정
|
||||
final iosInitSettings = DarwinInitializationSettings(
|
||||
requestAlertPermission: true,
|
||||
requestBadgePermission: true,
|
||||
requestSoundPermission: true,
|
||||
onDidReceiveLocalNotification: (id, title, body, payload) async {
|
||||
// iOS 9 이하 버전 대응
|
||||
},
|
||||
);
|
||||
|
||||
// macOS 초기화 설정
|
||||
final macOSInitSettings = DarwinInitializationSettings(
|
||||
requestAlertPermission: true,
|
||||
requestBadgePermission: true,
|
||||
requestSoundPermission: true,
|
||||
);
|
||||
|
||||
// 플랫폼별 초기화 설정 통합
|
||||
final initSettings = InitializationSettings(
|
||||
android: androidInitSettings,
|
||||
iOS: iosInitSettings,
|
||||
macOS: macOSInitSettings,
|
||||
);
|
||||
|
||||
// 알림 플러그인 초기화
|
||||
final initialized = await _notifications.initialize(
|
||||
initSettings,
|
||||
onDidReceiveNotificationResponse: _onNotificationTap,
|
||||
onDidReceiveBackgroundNotificationResponse: _onBackgroundNotificationTap,
|
||||
);
|
||||
|
||||
// Android 알림 채널 생성 (웹이 아닌 경우에만)
|
||||
if (!kIsWeb && defaultTargetPlatform == TargetPlatform.android) {
|
||||
await _createNotificationChannel();
|
||||
}
|
||||
|
||||
return initialized ?? false;
|
||||
}
|
||||
|
||||
/// Android 알림 채널 생성
|
||||
Future<void> _createNotificationChannel() async {
|
||||
const androidChannel = AndroidNotificationChannel(
|
||||
_channelId,
|
||||
_channelName,
|
||||
description: _channelDescription,
|
||||
importance: Importance.high,
|
||||
playSound: true,
|
||||
enableVibration: true,
|
||||
);
|
||||
|
||||
await _notifications
|
||||
.resolvePlatformSpecificImplementation<AndroidFlutterLocalNotificationsPlugin>()
|
||||
?.createNotificationChannel(androidChannel);
|
||||
}
|
||||
|
||||
/// 알림 권한 요청
|
||||
Future<bool> requestPermission() async {
|
||||
if (!kIsWeb && defaultTargetPlatform == TargetPlatform.android) {
|
||||
final androidImplementation = _notifications
|
||||
.resolvePlatformSpecificImplementation<AndroidFlutterLocalNotificationsPlugin>();
|
||||
|
||||
if (androidImplementation != null) {
|
||||
// Android 13 (API 33) 이상에서는 권한 요청이 필요
|
||||
if (!kIsWeb && (true /* 웹에서는 버전 체크 불가 */)) {
|
||||
final granted = await androidImplementation.requestNotificationsPermission();
|
||||
return granted ?? false;
|
||||
}
|
||||
// Android 12 이하는 자동 허용
|
||||
return true;
|
||||
}
|
||||
} else if (!kIsWeb && (defaultTargetPlatform == TargetPlatform.iOS || defaultTargetPlatform == TargetPlatform.macOS)) {
|
||||
final iosImplementation = _notifications
|
||||
.resolvePlatformSpecificImplementation<IOSFlutterLocalNotificationsPlugin>();
|
||||
final macosImplementation = _notifications
|
||||
.resolvePlatformSpecificImplementation<MacOSFlutterLocalNotificationsPlugin>();
|
||||
|
||||
if (iosImplementation != null) {
|
||||
final granted = await iosImplementation.requestPermissions(
|
||||
alert: true,
|
||||
badge: true,
|
||||
sound: true,
|
||||
);
|
||||
return granted ?? false;
|
||||
}
|
||||
|
||||
if (macosImplementation != null) {
|
||||
final granted = await macosImplementation.requestPermissions(
|
||||
alert: true,
|
||||
badge: true,
|
||||
sound: true,
|
||||
);
|
||||
return granted ?? false;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// 권한 상태 확인
|
||||
Future<bool> checkPermission() async {
|
||||
if (!kIsWeb && defaultTargetPlatform == TargetPlatform.android) {
|
||||
final androidImplementation = _notifications
|
||||
.resolvePlatformSpecificImplementation<AndroidFlutterLocalNotificationsPlugin>();
|
||||
|
||||
if (androidImplementation != null) {
|
||||
// Android 13 이상에서만 권한 확인
|
||||
if (!kIsWeb && (true /* 웹에서는 버전 체크 불가 */)) {
|
||||
final granted = await androidImplementation.areNotificationsEnabled();
|
||||
return granted ?? false;
|
||||
}
|
||||
// Android 12 이하는 자동 허용
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// iOS/macOS는 설정에서 확인
|
||||
return true;
|
||||
}
|
||||
|
||||
// 알림 탭 콜백
|
||||
static void Function(NotificationResponse)? onNotificationTap;
|
||||
|
||||
/// 방문 확인 알림 예약
|
||||
Future<void> scheduleVisitReminder({
|
||||
required String restaurantId,
|
||||
required String restaurantName,
|
||||
required DateTime recommendationTime,
|
||||
}) async {
|
||||
try {
|
||||
// 1.5~2시간 사이의 랜덤 시간 계산 (90~120분)
|
||||
final randomMinutes = 90 + Random().nextInt(31); // 90 + 0~30분
|
||||
final scheduledTime = tz.TZDateTime.now(tz.local).add(
|
||||
Duration(minutes: randomMinutes),
|
||||
);
|
||||
|
||||
// 알림 상세 설정
|
||||
final androidDetails = AndroidNotificationDetails(
|
||||
_channelId,
|
||||
_channelName,
|
||||
channelDescription: _channelDescription,
|
||||
importance: Importance.high,
|
||||
priority: Priority.high,
|
||||
ticker: '방문 확인',
|
||||
icon: '@mipmap/ic_launcher',
|
||||
autoCancel: true,
|
||||
enableVibration: true,
|
||||
playSound: true,
|
||||
);
|
||||
|
||||
const iosDetails = DarwinNotificationDetails(
|
||||
presentAlert: true,
|
||||
presentBadge: true,
|
||||
presentSound: true,
|
||||
sound: 'default',
|
||||
);
|
||||
|
||||
final notificationDetails = NotificationDetails(
|
||||
android: androidDetails,
|
||||
iOS: iosDetails,
|
||||
macOS: iosDetails,
|
||||
);
|
||||
|
||||
// 알림 예약
|
||||
await _notifications.zonedSchedule(
|
||||
_visitReminderNotificationId,
|
||||
'다녀왔음? 🍴',
|
||||
'$restaurantName 어땠어요? 방문 기록을 남겨주세요!',
|
||||
scheduledTime,
|
||||
notificationDetails,
|
||||
androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle,
|
||||
uiLocalNotificationDateInterpretation:
|
||||
UILocalNotificationDateInterpretation.absoluteTime,
|
||||
payload: 'visit_reminder|$restaurantId|$restaurantName|${recommendationTime.toIso8601String()}',
|
||||
);
|
||||
|
||||
if (kDebugMode) {
|
||||
print('알림 예약됨: ${scheduledTime.toLocal()} ($randomMinutes분 후)');
|
||||
}
|
||||
} catch (e) {
|
||||
if (kDebugMode) {
|
||||
print('알림 예약 실패: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 예약된 방문 확인 알림 취소
|
||||
Future<void> cancelVisitReminder() async {
|
||||
await _notifications.cancel(_visitReminderNotificationId);
|
||||
}
|
||||
|
||||
/// 모든 알림 취소
|
||||
Future<void> cancelAllNotifications() async {
|
||||
await _notifications.cancelAll();
|
||||
}
|
||||
|
||||
/// 예약된 알림 목록 조회
|
||||
Future<List<PendingNotificationRequest>> getPendingNotifications() async {
|
||||
return await _notifications.pendingNotificationRequests();
|
||||
}
|
||||
|
||||
/// 알림 탭 이벤트 처리
|
||||
void _onNotificationTap(NotificationResponse response) {
|
||||
if (onNotificationTap != null) {
|
||||
onNotificationTap!(response);
|
||||
} else if (response.payload != null) {
|
||||
if (kDebugMode) {
|
||||
print('알림 탭: ${response.payload}');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 백그라운드 알림 탭 이벤트 처리
|
||||
@pragma('vm:entry-point')
|
||||
static void _onBackgroundNotificationTap(NotificationResponse response) {
|
||||
if (onNotificationTap != null) {
|
||||
onNotificationTap!(response);
|
||||
} else if (response.payload != null) {
|
||||
if (kDebugMode) {
|
||||
print('백그라운드 알림 탭: ${response.payload}');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 즉시 알림 표시 (테스트용)
|
||||
Future<void> showImmediateNotification({
|
||||
required String title,
|
||||
required String body,
|
||||
}) async {
|
||||
const androidDetails = AndroidNotificationDetails(
|
||||
_channelId,
|
||||
_channelName,
|
||||
channelDescription: _channelDescription,
|
||||
importance: Importance.high,
|
||||
priority: Priority.high,
|
||||
);
|
||||
|
||||
const iosDetails = DarwinNotificationDetails();
|
||||
|
||||
const notificationDetails = NotificationDetails(
|
||||
android: androidDetails,
|
||||
iOS: iosDetails,
|
||||
macOS: iosDetails,
|
||||
);
|
||||
|
||||
await _notifications.show(
|
||||
0,
|
||||
title,
|
||||
body,
|
||||
notificationDetails,
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user