- add AppLogger and replace scattered print logging\n- implement ad-gated recommendation flow with reminder handling and calendar link\n- complete Bluetooth share pipeline with ad gate and merge\n- integrate KMA weather API with caching and dart-define decoding\n- add NaverUrlProcessor refactor and restore restaurant repository tests
406 lines
12 KiB
Dart
406 lines
12 KiB
Dart
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;
|
|
import '../utils/app_logger.dart';
|
|
|
|
/// 알림 서비스 싱글톤 클래스
|
|
class NotificationService {
|
|
// 싱글톤 인스턴스
|
|
static final NotificationService _instance = NotificationService._internal();
|
|
factory NotificationService() => _instance;
|
|
NotificationService._internal();
|
|
|
|
// Flutter Local Notifications 플러그인
|
|
final FlutterLocalNotificationsPlugin _notifications =
|
|
FlutterLocalNotificationsPlugin();
|
|
bool _initialized = false;
|
|
|
|
// 알림 채널 정보
|
|
static const String _channelId = 'lunchpick_visit_reminder';
|
|
static const String _channelName = '방문 확인 알림';
|
|
static const String _channelDescription = '점심 식사 후 방문을 확인하는 알림입니다.';
|
|
|
|
// 알림 ID (방문 확인용)
|
|
static const int _visitReminderNotificationId = 1;
|
|
bool _timezoneReady = false;
|
|
tz.Location? _cachedLocation;
|
|
|
|
/// 초기화 여부
|
|
bool get isInitialized => _initialized;
|
|
|
|
/// 초기화 및 권한 요청 보장
|
|
Future<bool> ensureInitialized({bool requestPermission = false}) async {
|
|
if (!_initialized) {
|
|
_initialized = await initialize();
|
|
}
|
|
|
|
if (!_initialized) return false;
|
|
|
|
if (requestPermission) {
|
|
final alreadyGranted = await checkPermission();
|
|
if (alreadyGranted) return true;
|
|
return await this.requestPermission();
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
/// 알림 서비스 초기화
|
|
Future<bool> initialize() async {
|
|
if (_initialized) return true;
|
|
if (kIsWeb) return false;
|
|
|
|
// 시간대 초기화
|
|
await _ensureLocalTimezone();
|
|
|
|
// 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,
|
|
);
|
|
|
|
// 알림 플러그인 초기화
|
|
try {
|
|
final initialized = await _notifications.initialize(
|
|
initSettings,
|
|
onDidReceiveNotificationResponse: _onNotificationTap,
|
|
onDidReceiveBackgroundNotificationResponse:
|
|
_onBackgroundNotificationTap,
|
|
);
|
|
_initialized = initialized ?? false;
|
|
} catch (e) {
|
|
_initialized = false;
|
|
AppLogger.debug('알림 초기화 실패: $e');
|
|
}
|
|
|
|
// Android 알림 채널 생성 (웹이 아닌 경우에만)
|
|
if (_initialized && defaultTargetPlatform == TargetPlatform.android) {
|
|
await _createNotificationChannel();
|
|
}
|
|
|
|
return _initialized;
|
|
}
|
|
|
|
/// 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) return false;
|
|
|
|
if (defaultTargetPlatform == TargetPlatform.android) {
|
|
final androidImplementation = _notifications
|
|
.resolvePlatformSpecificImplementation<
|
|
AndroidFlutterLocalNotificationsPlugin
|
|
>();
|
|
|
|
if (androidImplementation != null) {
|
|
final granted = await androidImplementation
|
|
.requestNotificationsPermission();
|
|
return granted ?? false;
|
|
}
|
|
} else if (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) return false;
|
|
|
|
if (defaultTargetPlatform == TargetPlatform.android) {
|
|
final androidImplementation = _notifications
|
|
.resolvePlatformSpecificImplementation<
|
|
AndroidFlutterLocalNotificationsPlugin
|
|
>();
|
|
|
|
if (androidImplementation != null) {
|
|
final granted = await androidImplementation.areNotificationsEnabled();
|
|
return granted ?? false;
|
|
}
|
|
}
|
|
|
|
if (defaultTargetPlatform == TargetPlatform.iOS ||
|
|
defaultTargetPlatform == TargetPlatform.macOS) {
|
|
final iosImplementation = _notifications
|
|
.resolvePlatformSpecificImplementation<
|
|
IOSFlutterLocalNotificationsPlugin
|
|
>();
|
|
final macosImplementation = _notifications
|
|
.resolvePlatformSpecificImplementation<
|
|
MacOSFlutterLocalNotificationsPlugin
|
|
>();
|
|
|
|
if (iosImplementation != null) {
|
|
final settings = await iosImplementation.checkPermissions();
|
|
return settings?.isEnabled ?? false;
|
|
}
|
|
|
|
if (macosImplementation != null) {
|
|
final settings = await macosImplementation.checkPermissions();
|
|
return settings?.isEnabled ?? false;
|
|
}
|
|
}
|
|
|
|
// iOS/macOS 외 플랫폼은 기본적으로 허용으로 간주
|
|
return true;
|
|
}
|
|
|
|
// 알림 탭 콜백
|
|
static void Function(NotificationResponse)? onNotificationTap;
|
|
|
|
/// 방문 확인 알림 예약
|
|
Future<void> scheduleVisitReminder({
|
|
required String restaurantId,
|
|
required String restaurantName,
|
|
required DateTime recommendationTime,
|
|
int? delayMinutes,
|
|
}) async {
|
|
try {
|
|
final ready = await ensureInitialized();
|
|
if (!ready) {
|
|
AppLogger.debug('알림 서비스가 초기화되지 않아 예약을 건너뜁니다.');
|
|
return;
|
|
}
|
|
|
|
final permissionGranted = await checkPermission();
|
|
if (!permissionGranted) {
|
|
AppLogger.debug('알림 권한이 없어 예약을 건너뜁니다.');
|
|
return;
|
|
}
|
|
|
|
final location = await _ensureLocalTimezone();
|
|
final minutesToWait = delayMinutes ?? 90 + Random().nextInt(31);
|
|
final scheduledTime = tz.TZDateTime.now(
|
|
location,
|
|
).add(Duration(minutes: minutesToWait));
|
|
|
|
// 알림 상세 설정
|
|
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: await _resolveAndroidScheduleMode(),
|
|
uiLocalNotificationDateInterpretation:
|
|
UILocalNotificationDateInterpretation.absoluteTime,
|
|
payload:
|
|
'visit_reminder|$restaurantId|$restaurantName|${recommendationTime.toIso8601String()}',
|
|
);
|
|
AppLogger.debug('알림 예약됨: ${scheduledTime.toLocal()} ($minutesToWait분 후)');
|
|
} catch (e) {
|
|
AppLogger.debug('알림 예약 실패: $e');
|
|
}
|
|
}
|
|
|
|
/// 예약된 방문 확인 알림 취소
|
|
Future<void> cancelVisitReminder() async {
|
|
if (!await ensureInitialized()) return;
|
|
await _notifications.cancel(_visitReminderNotificationId);
|
|
}
|
|
|
|
/// 모든 알림 취소
|
|
Future<void> cancelAllNotifications() async {
|
|
if (!await ensureInitialized()) return;
|
|
await _notifications.cancelAll();
|
|
}
|
|
|
|
/// 예약된 알림 목록 조회
|
|
Future<List<PendingNotificationRequest>> getPendingNotifications() async {
|
|
if (!await ensureInitialized()) return [];
|
|
return await _notifications.pendingNotificationRequests();
|
|
}
|
|
|
|
/// 방문 확인 알림이 예약되어 있는지 확인
|
|
Future<bool> hasVisitReminderScheduled() async {
|
|
if (!await ensureInitialized()) return false;
|
|
final pending = await getPendingNotifications();
|
|
return pending.any((item) => item.id == _visitReminderNotificationId);
|
|
}
|
|
|
|
/// 타임존을 안전하게 초기화하고 tz.local을 반환
|
|
Future<tz.Location> _ensureLocalTimezone() async {
|
|
if (_cachedLocation != null) return _cachedLocation!;
|
|
if (!_timezoneReady) {
|
|
try {
|
|
tz.initializeTimeZones();
|
|
} catch (_) {
|
|
// 초기화 실패 시에도 계속 진행
|
|
}
|
|
_timezoneReady = true;
|
|
}
|
|
|
|
try {
|
|
tz.setLocalLocation(tz.getLocation('Asia/Seoul'));
|
|
_cachedLocation = tz.local;
|
|
} catch (_) {
|
|
// 로컬 타임존을 가져오지 못하면 UTC로 강제 설정
|
|
tz.setLocalLocation(tz.UTC);
|
|
_cachedLocation = tz.UTC;
|
|
}
|
|
return _cachedLocation!;
|
|
}
|
|
|
|
/// 정확 알람 권한 여부에 따라 스케줄 모드 결정
|
|
Future<AndroidScheduleMode> _resolveAndroidScheduleMode() async {
|
|
if (kIsWeb || defaultTargetPlatform != TargetPlatform.android) {
|
|
return AndroidScheduleMode.exactAllowWhileIdle;
|
|
}
|
|
|
|
try {
|
|
final androidImplementation = _notifications
|
|
.resolvePlatformSpecificImplementation<
|
|
AndroidFlutterLocalNotificationsPlugin
|
|
>();
|
|
final canExact = await androidImplementation
|
|
?.canScheduleExactNotifications();
|
|
if (canExact == false) {
|
|
return AndroidScheduleMode.inexactAllowWhileIdle;
|
|
}
|
|
} catch (_) {
|
|
return AndroidScheduleMode.inexactAllowWhileIdle;
|
|
}
|
|
|
|
return AndroidScheduleMode.exactAllowWhileIdle;
|
|
}
|
|
|
|
/// 알림 탭 이벤트 처리
|
|
void _onNotificationTap(NotificationResponse response) {
|
|
if (onNotificationTap != null) {
|
|
onNotificationTap!(response);
|
|
} else if (response.payload != null) {
|
|
AppLogger.debug('알림 탭: ${response.payload}');
|
|
}
|
|
}
|
|
|
|
/// 백그라운드 알림 탭 이벤트 처리
|
|
@pragma('vm:entry-point')
|
|
static void _onBackgroundNotificationTap(NotificationResponse response) {
|
|
if (onNotificationTap != null) {
|
|
onNotificationTap!(response);
|
|
} else if (response.payload != null) {
|
|
AppLogger.debug('백그라운드 알림 탭: ${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);
|
|
}
|
|
}
|