feat(app): add manual entry and sharing flows
This commit is contained in:
143
lib/core/services/ad_service.dart
Normal file
143
lib/core/services/ad_service.dart
Normal file
@@ -0,0 +1,143 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// 간단한 전면 광고(Interstitial Ad) 모의 서비스
|
||||
class AdService {
|
||||
/// 임시 광고 다이얼로그를 표시하고 사용자가 끝까지 시청했는지 여부를 반환한다.
|
||||
Future<bool> showInterstitialAd(BuildContext context) async {
|
||||
final result = await showDialog<bool>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (_) => const _MockInterstitialAdDialog(),
|
||||
);
|
||||
return result ?? false;
|
||||
}
|
||||
}
|
||||
|
||||
class _MockInterstitialAdDialog extends StatefulWidget {
|
||||
const _MockInterstitialAdDialog();
|
||||
|
||||
@override
|
||||
State<_MockInterstitialAdDialog> createState() =>
|
||||
_MockInterstitialAdDialogState();
|
||||
}
|
||||
|
||||
class _MockInterstitialAdDialogState extends State<_MockInterstitialAdDialog> {
|
||||
static const int _adDurationSeconds = 4;
|
||||
|
||||
late Timer _timer;
|
||||
int _elapsedSeconds = 0;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_timer = Timer.periodic(const Duration(seconds: 1), (timer) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_elapsedSeconds++;
|
||||
});
|
||||
if (_elapsedSeconds >= _adDurationSeconds) {
|
||||
_timer.cancel();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_timer.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
bool get _canClose => _elapsedSeconds >= _adDurationSeconds;
|
||||
|
||||
double get _progress => (_elapsedSeconds / _adDurationSeconds).clamp(0, 1);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
|
||||
return Dialog(
|
||||
insetPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 80),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
||||
child: Stack(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.ondemand_video,
|
||||
size: 56,
|
||||
color: Colors.deepPurple,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'광고 시청 중...',
|
||||
style: TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: isDark ? Colors.white : Colors.black87,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
_canClose ? '광고가 완료되었습니다.' : '잠시만 기다려 주세요.',
|
||||
style: TextStyle(
|
||||
color: isDark ? Colors.white70 : Colors.black54,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
LinearProgressIndicator(
|
||||
value: _progress,
|
||||
minHeight: 6,
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
backgroundColor: Colors.grey.withValues(alpha: 0.2),
|
||||
color: Colors.deepPurple,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
_canClose
|
||||
? '이제 닫을 수 있어요.'
|
||||
: '남은 시간: ${_adDurationSeconds - _elapsedSeconds}초',
|
||||
style: TextStyle(
|
||||
color: isDark ? Colors.white70 : Colors.black54,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
FilledButton(
|
||||
onPressed: _canClose
|
||||
? () {
|
||||
Navigator.of(context).pop(true);
|
||||
}
|
||||
: null,
|
||||
style: FilledButton.styleFrom(
|
||||
minimumSize: const Size.fromHeight(48),
|
||||
backgroundColor: Colors.deepPurple,
|
||||
),
|
||||
child: const Text('추천 계속 보기'),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop(false);
|
||||
},
|
||||
child: const Text('닫기'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
right: 8,
|
||||
top: 8,
|
||||
child: IconButton(
|
||||
onPressed: () => Navigator.of(context).pop(false),
|
||||
icon: const Icon(Icons.close),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
89
lib/core/services/bluetooth_service.dart
Normal file
89
lib/core/services/bluetooth_service.dart
Normal file
@@ -0,0 +1,89 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:lunchpick/domain/entities/restaurant.dart';
|
||||
import 'package:lunchpick/domain/entities/share_device.dart';
|
||||
|
||||
/// 실제 Bluetooth 통신을 대체하는 간단한 모의(Mock) 서비스.
|
||||
class BluetoothService {
|
||||
final _incomingDataController = StreamController<String>.broadcast();
|
||||
final Map<String, ShareDevice> _listeningDevices = {};
|
||||
final Random _random = Random();
|
||||
|
||||
Stream<String> get onDataReceived => _incomingDataController.stream;
|
||||
|
||||
/// 특정 코드로 수신 대기를 시작한다.
|
||||
Future<void> startListening(String code) async {
|
||||
await Future<void>.delayed(const Duration(milliseconds: 300));
|
||||
stopListening();
|
||||
final shareDevice = ShareDevice(
|
||||
code: code,
|
||||
deviceId: 'LP-${_random.nextInt(900000) + 100000}',
|
||||
discoveredAt: DateTime.now(),
|
||||
);
|
||||
_listeningDevices[code] = shareDevice;
|
||||
}
|
||||
|
||||
/// 더 이상 수신 대기하지 않는다.
|
||||
void stopListening() {
|
||||
if (_listeningDevices.isEmpty) return;
|
||||
final codes = List<String>.from(_listeningDevices.keys);
|
||||
for (final code in codes) {
|
||||
_listeningDevices.remove(code);
|
||||
}
|
||||
}
|
||||
|
||||
/// 현재 주변에서 수신 대기 중인 기기 목록을 반환한다.
|
||||
Future<List<ShareDevice>> scanNearbyDevices() async {
|
||||
await Future<void>.delayed(const Duration(seconds: 1));
|
||||
return _listeningDevices.values.toList();
|
||||
}
|
||||
|
||||
/// 대상 코드로 맛집 리스트를 전송한다. 실제 BT 대신 JSON 문자열을 브로드캐스트한다.
|
||||
Future<void> sendRestaurantList(
|
||||
String targetCode,
|
||||
List<Restaurant> restaurants,
|
||||
) async {
|
||||
await Future<void>.delayed(const Duration(seconds: 1));
|
||||
if (!_listeningDevices.containsKey(targetCode)) {
|
||||
throw Exception('해당 코드를 찾을 수 없습니다.');
|
||||
}
|
||||
|
||||
final payload = jsonEncode(
|
||||
restaurants
|
||||
.map((restaurant) => _serializeRestaurant(restaurant))
|
||||
.toList(),
|
||||
);
|
||||
_incomingDataController.add(payload);
|
||||
}
|
||||
|
||||
Map<String, dynamic> _serializeRestaurant(Restaurant restaurant) {
|
||||
return {
|
||||
'id': restaurant.id,
|
||||
'name': restaurant.name,
|
||||
'category': restaurant.category,
|
||||
'subCategory': restaurant.subCategory,
|
||||
'description': restaurant.description,
|
||||
'phoneNumber': restaurant.phoneNumber,
|
||||
'roadAddress': restaurant.roadAddress,
|
||||
'jibunAddress': restaurant.jibunAddress,
|
||||
'latitude': restaurant.latitude,
|
||||
'longitude': restaurant.longitude,
|
||||
'lastVisitDate': restaurant.lastVisitDate?.toIso8601String(),
|
||||
'source': restaurant.source.name,
|
||||
'createdAt': restaurant.createdAt.toIso8601String(),
|
||||
'updatedAt': restaurant.updatedAt.toIso8601String(),
|
||||
'naverPlaceId': restaurant.naverPlaceId,
|
||||
'naverUrl': restaurant.naverUrl,
|
||||
'businessHours': restaurant.businessHours,
|
||||
'lastVisited': restaurant.lastVisited?.toIso8601String(),
|
||||
'visitCount': restaurant.visitCount,
|
||||
};
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
_incomingDataController.close();
|
||||
_listeningDevices.clear();
|
||||
}
|
||||
}
|
||||
@@ -12,13 +12,14 @@ class NotificationService {
|
||||
NotificationService._internal();
|
||||
|
||||
// Flutter Local Notifications 플러그인
|
||||
final FlutterLocalNotificationsPlugin _notifications = FlutterLocalNotificationsPlugin();
|
||||
|
||||
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;
|
||||
|
||||
@@ -26,10 +27,12 @@ class NotificationService {
|
||||
Future<bool> initialize() async {
|
||||
// 시간대 초기화
|
||||
tz.initializeTimeZones();
|
||||
|
||||
|
||||
// Android 초기화 설정
|
||||
const androidInitSettings = AndroidInitializationSettings('@mipmap/ic_launcher');
|
||||
|
||||
const androidInitSettings = AndroidInitializationSettings(
|
||||
'@mipmap/ic_launcher',
|
||||
);
|
||||
|
||||
// iOS 초기화 설정
|
||||
final iosInitSettings = DarwinInitializationSettings(
|
||||
requestAlertPermission: true,
|
||||
@@ -39,33 +42,33 @@ class NotificationService {
|
||||
// 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;
|
||||
}
|
||||
|
||||
@@ -79,9 +82,11 @@ class NotificationService {
|
||||
playSound: true,
|
||||
enableVibration: true,
|
||||
);
|
||||
|
||||
|
||||
await _notifications
|
||||
.resolvePlatformSpecificImplementation<AndroidFlutterLocalNotificationsPlugin>()
|
||||
.resolvePlatformSpecificImplementation<
|
||||
AndroidFlutterLocalNotificationsPlugin
|
||||
>()
|
||||
?.createNotificationChannel(androidChannel);
|
||||
}
|
||||
|
||||
@@ -89,23 +94,32 @@ class NotificationService {
|
||||
Future<bool> requestPermission() async {
|
||||
if (!kIsWeb && defaultTargetPlatform == TargetPlatform.android) {
|
||||
final androidImplementation = _notifications
|
||||
.resolvePlatformSpecificImplementation<AndroidFlutterLocalNotificationsPlugin>();
|
||||
|
||||
.resolvePlatformSpecificImplementation<
|
||||
AndroidFlutterLocalNotificationsPlugin
|
||||
>();
|
||||
|
||||
if (androidImplementation != null) {
|
||||
// Android 13 (API 33) 이상에서는 권한 요청이 필요
|
||||
if (!kIsWeb && (true /* 웹에서는 버전 체크 불가 */)) {
|
||||
final granted = await androidImplementation.requestNotificationsPermission();
|
||||
if (!kIsWeb && (true /* 웹에서는 버전 체크 불가 */ )) {
|
||||
final granted = await androidImplementation
|
||||
.requestNotificationsPermission();
|
||||
return granted ?? false;
|
||||
}
|
||||
// Android 12 이하는 자동 허용
|
||||
return true;
|
||||
}
|
||||
} else if (!kIsWeb && (defaultTargetPlatform == TargetPlatform.iOS || defaultTargetPlatform == TargetPlatform.macOS)) {
|
||||
} else if (!kIsWeb &&
|
||||
(defaultTargetPlatform == TargetPlatform.iOS ||
|
||||
defaultTargetPlatform == TargetPlatform.macOS)) {
|
||||
final iosImplementation = _notifications
|
||||
.resolvePlatformSpecificImplementation<IOSFlutterLocalNotificationsPlugin>();
|
||||
.resolvePlatformSpecificImplementation<
|
||||
IOSFlutterLocalNotificationsPlugin
|
||||
>();
|
||||
final macosImplementation = _notifications
|
||||
.resolvePlatformSpecificImplementation<MacOSFlutterLocalNotificationsPlugin>();
|
||||
|
||||
.resolvePlatformSpecificImplementation<
|
||||
MacOSFlutterLocalNotificationsPlugin
|
||||
>();
|
||||
|
||||
if (iosImplementation != null) {
|
||||
final granted = await iosImplementation.requestPermissions(
|
||||
alert: true,
|
||||
@@ -114,7 +128,7 @@ class NotificationService {
|
||||
);
|
||||
return granted ?? false;
|
||||
}
|
||||
|
||||
|
||||
if (macosImplementation != null) {
|
||||
final granted = await macosImplementation.requestPermissions(
|
||||
alert: true,
|
||||
@@ -124,7 +138,7 @@ class NotificationService {
|
||||
return granted ?? false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -132,11 +146,13 @@ class NotificationService {
|
||||
Future<bool> checkPermission() async {
|
||||
if (!kIsWeb && defaultTargetPlatform == TargetPlatform.android) {
|
||||
final androidImplementation = _notifications
|
||||
.resolvePlatformSpecificImplementation<AndroidFlutterLocalNotificationsPlugin>();
|
||||
|
||||
.resolvePlatformSpecificImplementation<
|
||||
AndroidFlutterLocalNotificationsPlugin
|
||||
>();
|
||||
|
||||
if (androidImplementation != null) {
|
||||
// Android 13 이상에서만 권한 확인
|
||||
if (!kIsWeb && (true /* 웹에서는 버전 체크 불가 */)) {
|
||||
if (!kIsWeb && (true /* 웹에서는 버전 체크 불가 */ )) {
|
||||
final granted = await androidImplementation.areNotificationsEnabled();
|
||||
return granted ?? false;
|
||||
}
|
||||
@@ -144,27 +160,27 @@ class NotificationService {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 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 {
|
||||
// 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 minutesToWait = delayMinutes ?? 90 + Random().nextInt(31);
|
||||
final scheduledTime = tz.TZDateTime.now(
|
||||
tz.local,
|
||||
).add(Duration(minutes: minutesToWait));
|
||||
|
||||
// 알림 상세 설정
|
||||
final androidDetails = AndroidNotificationDetails(
|
||||
_channelId,
|
||||
@@ -178,20 +194,20 @@ class NotificationService {
|
||||
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,
|
||||
@@ -202,11 +218,11 @@ class NotificationService {
|
||||
androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle,
|
||||
uiLocalNotificationDateInterpretation:
|
||||
UILocalNotificationDateInterpretation.absoluteTime,
|
||||
payload: 'visit_reminder|$restaurantId|$restaurantName|${recommendationTime.toIso8601String()}',
|
||||
payload:
|
||||
'visit_reminder|$restaurantId|$restaurantName|${recommendationTime.toIso8601String()}',
|
||||
);
|
||||
|
||||
if (kDebugMode) {
|
||||
print('알림 예약됨: ${scheduledTime.toLocal()} ($randomMinutes분 후)');
|
||||
print('알림 예약됨: ${scheduledTime.toLocal()} ($minutesToWait분 후)');
|
||||
}
|
||||
} catch (e) {
|
||||
if (kDebugMode) {
|
||||
@@ -265,20 +281,15 @@ class NotificationService {
|
||||
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,
|
||||
);
|
||||
|
||||
await _notifications.show(0, title, body, notificationDetails);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
31
lib/core/services/permission_service.dart
Normal file
31
lib/core/services/permission_service.dart
Normal file
@@ -0,0 +1,31 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
|
||||
/// 공용 권한 유틸리티
|
||||
class PermissionService {
|
||||
static Future<bool> checkAndRequestBluetoothPermission() async {
|
||||
if (!Platform.isAndroid && !Platform.isIOS) {
|
||||
return true;
|
||||
}
|
||||
|
||||
final permissions = <Permission>[
|
||||
Permission.bluetooth,
|
||||
Permission.bluetoothScan,
|
||||
Permission.bluetoothConnect,
|
||||
Permission.bluetoothAdvertise,
|
||||
];
|
||||
|
||||
for (final permission in permissions) {
|
||||
final status = await permission.status;
|
||||
if (status.isGranted) {
|
||||
continue;
|
||||
}
|
||||
final result = await permission.request();
|
||||
if (!result.isGranted) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user