feat(app): finalize ad gated flows and weather
- 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
This commit is contained in:
@@ -3,9 +3,12 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:table_calendar/table_calendar.dart';
|
||||
import '../../../core/constants/app_colors.dart';
|
||||
import '../../../core/constants/app_typography.dart';
|
||||
import '../../../domain/entities/recommendation_record.dart';
|
||||
import '../../../domain/entities/visit_record.dart';
|
||||
import '../../providers/recommendation_provider.dart';
|
||||
import '../../providers/visit_provider.dart';
|
||||
import 'widgets/visit_record_card.dart';
|
||||
import 'widgets/recommendation_record_card.dart';
|
||||
import 'widgets/visit_statistics.dart';
|
||||
|
||||
class CalendarScreen extends ConsumerStatefulWidget {
|
||||
@@ -21,7 +24,7 @@ class _CalendarScreenState extends ConsumerState<CalendarScreen>
|
||||
late DateTime _focusedDay;
|
||||
CalendarFormat _calendarFormat = CalendarFormat.month;
|
||||
late TabController _tabController;
|
||||
Map<DateTime, List<VisitRecord>> _visitRecordEvents = {};
|
||||
Map<DateTime, List<_CalendarEvent>> _events = {};
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -37,9 +40,9 @@ class _CalendarScreenState extends ConsumerState<CalendarScreen>
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
List<VisitRecord> _getEventsForDay(DateTime day) {
|
||||
List<_CalendarEvent> _getEventsForDay(DateTime day) {
|
||||
final normalizedDay = DateTime(day.year, day.month, day.day);
|
||||
return _visitRecordEvents[normalizedDay] ?? [];
|
||||
return _events[normalizedDay] ?? [];
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -83,22 +86,17 @@ class _CalendarScreenState extends ConsumerState<CalendarScreen>
|
||||
return Consumer(
|
||||
builder: (context, ref, child) {
|
||||
final visitRecordsAsync = ref.watch(visitRecordsProvider);
|
||||
final recommendationRecordsAsync = ref.watch(
|
||||
recommendationRecordsProvider,
|
||||
);
|
||||
|
||||
// 방문 기록을 날짜별로 그룹화
|
||||
visitRecordsAsync.whenData((records) {
|
||||
_visitRecordEvents = {};
|
||||
for (final record in records) {
|
||||
final normalizedDate = DateTime(
|
||||
record.visitDate.year,
|
||||
record.visitDate.month,
|
||||
record.visitDate.day,
|
||||
);
|
||||
_visitRecordEvents[normalizedDate] = [
|
||||
...(_visitRecordEvents[normalizedDate] ?? []),
|
||||
record,
|
||||
];
|
||||
}
|
||||
});
|
||||
if (visitRecordsAsync.hasValue && recommendationRecordsAsync.hasValue) {
|
||||
final visits = visitRecordsAsync.value ?? [];
|
||||
final recommendations =
|
||||
recommendationRecordsAsync.valueOrNull ??
|
||||
<RecommendationRecord>[];
|
||||
_events = _buildEvents(visits, recommendations);
|
||||
}
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
@@ -132,17 +130,18 @@ class _CalendarScreenState extends ConsumerState<CalendarScreen>
|
||||
markerBuilder: (context, day, events) {
|
||||
if (events.isEmpty) return null;
|
||||
|
||||
final visitRecords = events.cast<VisitRecord>();
|
||||
final confirmedCount = visitRecords
|
||||
.where((r) => r.isConfirmed)
|
||||
.length;
|
||||
final unconfirmedCount =
|
||||
visitRecords.length - confirmedCount;
|
||||
final calendarEvents = events.cast<_CalendarEvent>();
|
||||
final confirmedVisits = calendarEvents.where(
|
||||
(e) => e.visitRecord?.isConfirmed == true,
|
||||
);
|
||||
final recommendedOnly = calendarEvents.where(
|
||||
(e) => e.recommendationRecord != null,
|
||||
);
|
||||
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
if (confirmedCount > 0)
|
||||
if (confirmedVisits.isNotEmpty)
|
||||
Container(
|
||||
width: 6,
|
||||
height: 6,
|
||||
@@ -152,7 +151,7 @@ class _CalendarScreenState extends ConsumerState<CalendarScreen>
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
if (unconfirmedCount > 0)
|
||||
if (recommendedOnly.isNotEmpty)
|
||||
Container(
|
||||
width: 6,
|
||||
height: 6,
|
||||
@@ -239,6 +238,7 @@ class _CalendarScreenState extends ConsumerState<CalendarScreen>
|
||||
|
||||
Widget _buildDayRecords(DateTime day, bool isDark) {
|
||||
final events = _getEventsForDay(day);
|
||||
events.sort((a, b) => b.sortDate.compareTo(a.sortDate));
|
||||
|
||||
if (events.isEmpty) {
|
||||
return Center(
|
||||
@@ -294,18 +294,71 @@ class _CalendarScreenState extends ConsumerState<CalendarScreen>
|
||||
child: ListView.builder(
|
||||
itemCount: events.length,
|
||||
itemBuilder: (context, index) {
|
||||
final sortedEvents = events
|
||||
..sort((a, b) => b.visitDate.compareTo(a.visitDate));
|
||||
return VisitRecordCard(
|
||||
visitRecord: sortedEvents[index],
|
||||
onTap: () {
|
||||
// TODO: 맛집 상세 페이지로 이동
|
||||
},
|
||||
);
|
||||
final event = events[index];
|
||||
if (event.visitRecord != null) {
|
||||
return VisitRecordCard(
|
||||
visitRecord: event.visitRecord!,
|
||||
onTap: () {},
|
||||
);
|
||||
}
|
||||
if (event.recommendationRecord != null) {
|
||||
return RecommendationRecordCard(
|
||||
recommendation: event.recommendationRecord!,
|
||||
onConfirmVisit: () async {
|
||||
await ref
|
||||
.read(recommendationNotifierProvider.notifier)
|
||||
.confirmVisit(event.recommendationRecord!.id);
|
||||
},
|
||||
);
|
||||
}
|
||||
return const SizedBox.shrink();
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Map<DateTime, List<_CalendarEvent>> _buildEvents(
|
||||
List<VisitRecord> visits,
|
||||
List<RecommendationRecord> recommendations,
|
||||
) {
|
||||
final Map<DateTime, List<_CalendarEvent>> events = {};
|
||||
|
||||
for (final visit in visits) {
|
||||
final day = DateTime(
|
||||
visit.visitDate.year,
|
||||
visit.visitDate.month,
|
||||
visit.visitDate.day,
|
||||
);
|
||||
events[day] = [
|
||||
...(events[day] ?? []),
|
||||
_CalendarEvent(visitRecord: visit),
|
||||
];
|
||||
}
|
||||
|
||||
for (final reco in recommendations.where((r) => !r.visited)) {
|
||||
final day = DateTime(
|
||||
reco.recommendationDate.year,
|
||||
reco.recommendationDate.month,
|
||||
reco.recommendationDate.day,
|
||||
);
|
||||
events[day] = [
|
||||
...(events[day] ?? []),
|
||||
_CalendarEvent(recommendationRecord: reco),
|
||||
];
|
||||
}
|
||||
|
||||
return events;
|
||||
}
|
||||
}
|
||||
|
||||
class _CalendarEvent {
|
||||
final VisitRecord? visitRecord;
|
||||
final RecommendationRecord? recommendationRecord;
|
||||
|
||||
_CalendarEvent({this.visitRecord, this.recommendationRecord});
|
||||
|
||||
DateTime get sortDate =>
|
||||
visitRecord?.visitDate ?? recommendationRecord!.recommendationDate;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:lunchpick/core/constants/app_colors.dart';
|
||||
import 'package:lunchpick/core/constants/app_typography.dart';
|
||||
import 'package:lunchpick/domain/entities/recommendation_record.dart';
|
||||
import 'package:lunchpick/presentation/providers/restaurant_provider.dart';
|
||||
|
||||
class RecommendationRecordCard extends ConsumerWidget {
|
||||
final RecommendationRecord recommendation;
|
||||
final VoidCallback onConfirmVisit;
|
||||
|
||||
const RecommendationRecordCard({
|
||||
super.key,
|
||||
required this.recommendation,
|
||||
required this.onConfirmVisit,
|
||||
});
|
||||
|
||||
String _formatTime(DateTime dateTime) {
|
||||
final hour = dateTime.hour.toString().padLeft(2, '0');
|
||||
final minute = dateTime.minute.toString().padLeft(2, '0');
|
||||
return '$hour:$minute';
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
final restaurantAsync = ref.watch(
|
||||
restaurantProvider(recommendation.restaurantId),
|
||||
);
|
||||
|
||||
return restaurantAsync.when(
|
||||
data: (restaurant) {
|
||||
if (restaurant == null) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
return Card(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
color: isDark ? AppColors.darkSurface : AppColors.lightSurface,
|
||||
elevation: 2,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.orange.withValues(alpha: 0.1),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.whatshot,
|
||||
color: Colors.orange,
|
||||
size: 24,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
restaurant.name,
|
||||
style: AppTypography.body1(
|
||||
isDark,
|
||||
).copyWith(fontWeight: FontWeight.bold),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.category_outlined,
|
||||
size: 14,
|
||||
color: isDark
|
||||
? AppColors.darkTextSecondary
|
||||
: AppColors.lightTextSecondary,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
restaurant.category,
|
||||
style: AppTypography.caption(isDark),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Icon(
|
||||
Icons.access_time,
|
||||
size: 14,
|
||||
color: isDark
|
||||
? AppColors.darkTextSecondary
|
||||
: AppColors.lightTextSecondary,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
_formatTime(recommendation.recommendationDate),
|
||||
style: AppTypography.caption(isDark),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.info_outline,
|
||||
size: 16,
|
||||
color: Colors.orange,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
'추천만 받은 상태입니다. 방문 후 확인을 눌러 주세요.',
|
||||
style: AppTypography.caption(isDark).copyWith(
|
||||
color: Colors.orange,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
ElevatedButton(
|
||||
onPressed: onConfirmVisit,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppColors.lightPrimary,
|
||||
foregroundColor: Colors.white,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
minimumSize: const Size(0, 40),
|
||||
),
|
||||
child: const Text('방문 확인'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
loading: () => const Card(
|
||||
margin: EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(16),
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
),
|
||||
),
|
||||
error: (_, __) => const SizedBox.shrink(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:geolocator/geolocator.dart';
|
||||
@@ -28,6 +29,7 @@ class RandomSelectionScreen extends ConsumerStatefulWidget {
|
||||
class _RandomSelectionScreenState extends ConsumerState<RandomSelectionScreen> {
|
||||
double _distanceValue = 500;
|
||||
final List<String> _selectedCategories = [];
|
||||
final List<String> _excludedRestaurantIds = [];
|
||||
bool _isProcessingRecommendation = false;
|
||||
|
||||
@override
|
||||
@@ -459,18 +461,28 @@ class _RandomSelectionScreenState extends ConsumerState<RandomSelectionScreen> {
|
||||
return count > 0;
|
||||
}
|
||||
|
||||
Future<void> _startRecommendation({bool skipAd = false}) async {
|
||||
Future<void> _startRecommendation({
|
||||
bool skipAd = false,
|
||||
bool isReroll = false,
|
||||
}) async {
|
||||
if (_isProcessingRecommendation) return;
|
||||
|
||||
if (!isReroll) {
|
||||
_excludedRestaurantIds.clear();
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_isProcessingRecommendation = true;
|
||||
});
|
||||
|
||||
try {
|
||||
final candidate = await _generateRecommendationCandidate();
|
||||
final candidate = await _generateRecommendationCandidate(
|
||||
excludedRestaurantIds: _excludedRestaurantIds,
|
||||
);
|
||||
if (candidate == null) {
|
||||
return;
|
||||
}
|
||||
final recommendedAt = DateTime.now();
|
||||
|
||||
if (!skipAd) {
|
||||
final adService = ref.read(adServiceProvider);
|
||||
@@ -488,7 +500,7 @@ class _RandomSelectionScreenState extends ConsumerState<RandomSelectionScreen> {
|
||||
}
|
||||
|
||||
if (!mounted) return;
|
||||
_showRecommendationDialog(candidate);
|
||||
await _showRecommendationDialog(candidate, recommendedAt: recommendedAt);
|
||||
} catch (_) {
|
||||
_showSnack(
|
||||
'추천을 준비하는 중 문제가 발생했습니다.',
|
||||
@@ -503,12 +515,16 @@ class _RandomSelectionScreenState extends ConsumerState<RandomSelectionScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<Restaurant?> _generateRecommendationCandidate() async {
|
||||
Future<Restaurant?> _generateRecommendationCandidate({
|
||||
List<String> excludedRestaurantIds = const [],
|
||||
}) async {
|
||||
final notifier = ref.read(recommendationNotifierProvider.notifier);
|
||||
|
||||
await notifier.getRandomRecommendation(
|
||||
final recommendation = await notifier.getRandomRecommendation(
|
||||
maxDistance: _distanceValue,
|
||||
selectedCategories: _selectedCategories,
|
||||
excludedRestaurantIds: excludedRestaurantIds,
|
||||
shouldSaveRecord: false,
|
||||
);
|
||||
|
||||
final result = ref.read(recommendationNotifierProvider);
|
||||
@@ -522,49 +538,82 @@ class _RandomSelectionScreenState extends ConsumerState<RandomSelectionScreen> {
|
||||
return null;
|
||||
}
|
||||
|
||||
final restaurant = result.asData?.value;
|
||||
if (restaurant == null) {
|
||||
_showSnack('조건에 맞는 식당이 존재하지 않습니다', backgroundColor: AppColors.lightError);
|
||||
if (recommendation == null) {
|
||||
_showSnack(
|
||||
'조건에 맞는 식당이 존재하지 않습니다. 광고는 재생되지 않았습니다.',
|
||||
backgroundColor: AppColors.lightError,
|
||||
);
|
||||
}
|
||||
return restaurant;
|
||||
return recommendation;
|
||||
}
|
||||
|
||||
void _showRecommendationDialog(Restaurant restaurant) {
|
||||
showDialog(
|
||||
Future<void> _showRecommendationDialog(
|
||||
Restaurant restaurant, {
|
||||
DateTime? recommendedAt,
|
||||
}) async {
|
||||
final result = await showDialog<RecommendationDialogResult>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (dialogContext) => RecommendationResultDialog(
|
||||
restaurant: restaurant,
|
||||
onReroll: () async {
|
||||
Navigator.pop(dialogContext);
|
||||
await _startRecommendation(skipAd: true);
|
||||
},
|
||||
onClose: () async {
|
||||
Navigator.pop(dialogContext);
|
||||
await _handleRecommendationAccepted(restaurant);
|
||||
},
|
||||
),
|
||||
builder: (dialogContext) =>
|
||||
RecommendationResultDialog(restaurant: restaurant),
|
||||
);
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
switch (result) {
|
||||
case RecommendationDialogResult.reroll:
|
||||
setState(() {
|
||||
_excludedRestaurantIds.add(restaurant.id);
|
||||
});
|
||||
await _startRecommendation(skipAd: true, isReroll: true);
|
||||
break;
|
||||
case RecommendationDialogResult.confirm:
|
||||
case RecommendationDialogResult.autoConfirm:
|
||||
default:
|
||||
await _handleRecommendationAccepted(
|
||||
restaurant,
|
||||
recommendedAt ?? DateTime.now(),
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _handleRecommendationAccepted(Restaurant restaurant) async {
|
||||
final recommendationTime = DateTime.now();
|
||||
|
||||
Future<void> _handleRecommendationAccepted(
|
||||
Restaurant restaurant,
|
||||
DateTime recommendationTime,
|
||||
) async {
|
||||
try {
|
||||
final recommendationNotifier = ref.read(
|
||||
recommendationNotifierProvider.notifier,
|
||||
);
|
||||
await recommendationNotifier.saveRecommendationRecord(
|
||||
restaurant,
|
||||
recommendationTime: recommendationTime,
|
||||
);
|
||||
|
||||
final notificationEnabled = await ref.read(
|
||||
notificationEnabledProvider.future,
|
||||
);
|
||||
if (notificationEnabled) {
|
||||
final delayMinutes = await ref.read(
|
||||
notificationDelayMinutesProvider.future,
|
||||
);
|
||||
bool notificationScheduled = false;
|
||||
if (notificationEnabled && !kIsWeb) {
|
||||
final notificationService = ref.read(notificationServiceProvider);
|
||||
await notificationService.scheduleVisitReminder(
|
||||
restaurantId: restaurant.id,
|
||||
restaurantName: restaurant.name,
|
||||
recommendationTime: recommendationTime,
|
||||
delayMinutes: delayMinutes,
|
||||
final notificationReady = await notificationService.ensureInitialized(
|
||||
requestPermission: true,
|
||||
);
|
||||
|
||||
if (notificationReady) {
|
||||
final delayMinutes = await ref.read(
|
||||
notificationDelayMinutesProvider.future,
|
||||
);
|
||||
await notificationService.scheduleVisitReminder(
|
||||
restaurantId: restaurant.id,
|
||||
restaurantName: restaurant.name,
|
||||
recommendationTime: recommendationTime,
|
||||
delayMinutes: delayMinutes,
|
||||
);
|
||||
notificationScheduled = await notificationService
|
||||
.hasVisitReminderScheduled();
|
||||
}
|
||||
}
|
||||
|
||||
await ref
|
||||
@@ -574,7 +623,19 @@ class _RandomSelectionScreenState extends ConsumerState<RandomSelectionScreen> {
|
||||
recommendationTime: recommendationTime,
|
||||
);
|
||||
|
||||
_showSnack('맛있게 드세요! 🍴');
|
||||
if (notificationEnabled && !notificationScheduled && !kIsWeb) {
|
||||
_showSnack(
|
||||
'방문 기록은 저장됐지만 알림 권한이나 설정을 확인해 주세요. 방문 알림을 예약하지 못했습니다.',
|
||||
backgroundColor: AppColors.lightError,
|
||||
);
|
||||
} else {
|
||||
_showSnack('맛있게 드세요! 🍴');
|
||||
}
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_excludedRestaurantIds.clear();
|
||||
});
|
||||
}
|
||||
} catch (_) {
|
||||
_showSnack(
|
||||
'방문 기록 또는 알림 예약에 실패했습니다.',
|
||||
@@ -588,10 +649,22 @@ class _RandomSelectionScreenState extends ConsumerState<RandomSelectionScreen> {
|
||||
Color backgroundColor = AppColors.lightPrimary,
|
||||
}) {
|
||||
if (!mounted) return;
|
||||
final topInset = MediaQuery.of(context).viewPadding.top;
|
||||
ScaffoldMessenger.of(context)
|
||||
..hideCurrentSnackBar()
|
||||
..showSnackBar(
|
||||
SnackBar(content: Text(message), backgroundColor: backgroundColor),
|
||||
SnackBar(
|
||||
content: Text(message),
|
||||
backgroundColor: backgroundColor,
|
||||
behavior: SnackBarBehavior.floating,
|
||||
margin: EdgeInsets.fromLTRB(
|
||||
16,
|
||||
(topInset > 0 ? topInset : 16) + 8,
|
||||
16,
|
||||
0,
|
||||
),
|
||||
duration: const Duration(seconds: 3),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,215 +1,258 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:lunchpick/core/constants/app_colors.dart';
|
||||
import 'package:lunchpick/core/constants/app_typography.dart';
|
||||
import 'package:lunchpick/domain/entities/restaurant.dart';
|
||||
|
||||
class RecommendationResultDialog extends StatelessWidget {
|
||||
enum RecommendationDialogResult { confirm, reroll, autoConfirm }
|
||||
|
||||
class RecommendationResultDialog extends StatefulWidget {
|
||||
final Restaurant restaurant;
|
||||
final Future<void> Function() onReroll;
|
||||
final Future<void> Function() onClose;
|
||||
final Duration autoConfirmDuration;
|
||||
|
||||
const RecommendationResultDialog({
|
||||
super.key,
|
||||
required this.restaurant,
|
||||
required this.onReroll,
|
||||
required this.onClose,
|
||||
this.autoConfirmDuration = const Duration(seconds: 12),
|
||||
});
|
||||
|
||||
@override
|
||||
State<RecommendationResultDialog> createState() =>
|
||||
_RecommendationResultDialogState();
|
||||
}
|
||||
|
||||
class _RecommendationResultDialogState
|
||||
extends State<RecommendationResultDialog> {
|
||||
Timer? _autoConfirmTimer;
|
||||
bool _didComplete = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_startAutoConfirmTimer();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_autoConfirmTimer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _startAutoConfirmTimer() {
|
||||
_autoConfirmTimer = Timer(widget.autoConfirmDuration, () {
|
||||
if (!mounted || _didComplete) return;
|
||||
_didComplete = true;
|
||||
Navigator.of(context).pop(RecommendationDialogResult.autoConfirm);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _handleResult(RecommendationDialogResult result) async {
|
||||
if (_didComplete) return;
|
||||
_didComplete = true;
|
||||
_autoConfirmTimer?.cancel();
|
||||
Navigator.of(context).pop(result);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
|
||||
return Dialog(
|
||||
backgroundColor: Colors.transparent,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: isDark ? AppColors.darkSurface : AppColors.lightSurface,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// 상단 이미지 영역
|
||||
Container(
|
||||
height: 150,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.lightPrimary,
|
||||
borderRadius: const BorderRadius.vertical(
|
||||
top: Radius.circular(20),
|
||||
return WillPopScope(
|
||||
onWillPop: () async {
|
||||
await _handleResult(RecommendationDialogResult.confirm);
|
||||
return true;
|
||||
},
|
||||
child: Dialog(
|
||||
backgroundColor: Colors.transparent,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: isDark ? AppColors.darkSurface : AppColors.lightSurface,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
height: 150,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.lightPrimary,
|
||||
borderRadius: const BorderRadius.vertical(
|
||||
top: Radius.circular(20),
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Stack(
|
||||
children: [
|
||||
Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.restaurant_menu,
|
||||
size: 64,
|
||||
color: Colors.white,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'오늘의 추천!',
|
||||
style: AppTypography.heading2(
|
||||
false,
|
||||
).copyWith(color: Colors.white),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
top: 8,
|
||||
right: 8,
|
||||
child: IconButton(
|
||||
icon: const Icon(Icons.close, color: Colors.white),
|
||||
onPressed: () async {
|
||||
await onClose();
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// 맛집 정보
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 가게 이름
|
||||
Center(
|
||||
child: Text(
|
||||
restaurant.name,
|
||||
style: AppTypography.heading1(isDark),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// 카테고리
|
||||
Center(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 4,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.lightPrimary.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Text(
|
||||
'${restaurant.category} > ${restaurant.subCategory}',
|
||||
style: AppTypography.body2(
|
||||
isDark,
|
||||
).copyWith(color: AppColors.lightPrimary),
|
||||
child: Stack(
|
||||
children: [
|
||||
Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.restaurant_menu,
|
||||
size: 64,
|
||||
color: Colors.white,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'오늘의 추천!',
|
||||
style: AppTypography.heading2(
|
||||
false,
|
||||
).copyWith(color: Colors.white),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
if (restaurant.description != null) ...[
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
restaurant.description!,
|
||||
style: AppTypography.body2(isDark),
|
||||
textAlign: TextAlign.center,
|
||||
Positioned(
|
||||
top: 8,
|
||||
right: 8,
|
||||
child: IconButton(
|
||||
icon: const Icon(Icons.close, color: Colors.white),
|
||||
onPressed: () async {
|
||||
await _handleResult(
|
||||
RecommendationDialogResult.confirm,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
const SizedBox(height: 16),
|
||||
const Divider(),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 주소
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.location_on,
|
||||
size: 20,
|
||||
color: isDark
|
||||
? AppColors.darkTextSecondary
|
||||
: AppColors.lightTextSecondary,
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Center(
|
||||
child: Text(
|
||||
widget.restaurant.name,
|
||||
style: AppTypography.heading1(isDark),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Center(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 4,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.lightPrimary.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Text(
|
||||
restaurant.roadAddress,
|
||||
style: AppTypography.body2(isDark),
|
||||
'${widget.restaurant.category} > ${widget.restaurant.subCategory}',
|
||||
style: AppTypography.body2(
|
||||
isDark,
|
||||
).copyWith(color: AppColors.lightPrimary),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (widget.restaurant.description != null) ...[
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
widget.restaurant.description!,
|
||||
style: AppTypography.body2(isDark),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
if (restaurant.phoneNumber != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
const SizedBox(height: 16),
|
||||
const Divider(),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.phone,
|
||||
Icons.location_on,
|
||||
size: 20,
|
||||
color: isDark
|
||||
? AppColors.darkTextSecondary
|
||||
: AppColors.lightTextSecondary,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
restaurant.phoneNumber!,
|
||||
style: AppTypography.body2(isDark),
|
||||
Expanded(
|
||||
child: Text(
|
||||
widget.restaurant.roadAddress,
|
||||
style: AppTypography.body2(isDark),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// 버튼들
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlinedButton(
|
||||
onPressed: () async {
|
||||
await onReroll();
|
||||
},
|
||||
style: OutlinedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
side: const BorderSide(
|
||||
color: AppColors.lightPrimary,
|
||||
),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
if (widget.restaurant.phoneNumber != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.phone,
|
||||
size: 20,
|
||||
color: isDark
|
||||
? AppColors.darkTextSecondary
|
||||
: AppColors.lightTextSecondary,
|
||||
),
|
||||
child: const Text(
|
||||
'다시 뽑기',
|
||||
style: TextStyle(color: AppColors.lightPrimary),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
widget.restaurant.phoneNumber!,
|
||||
style: AppTypography.body2(isDark),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: ElevatedButton(
|
||||
onPressed: () async {
|
||||
await onClose();
|
||||
},
|
||||
style: ElevatedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
backgroundColor: AppColors.lightPrimary,
|
||||
foregroundColor: Colors.white,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
child: const Text('닫기'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 24),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlinedButton(
|
||||
onPressed: () async {
|
||||
await _handleResult(
|
||||
RecommendationDialogResult.reroll,
|
||||
);
|
||||
},
|
||||
style: OutlinedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
side: const BorderSide(
|
||||
color: AppColors.lightPrimary,
|
||||
),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
child: const Text(
|
||||
'다시 뽑기',
|
||||
style: TextStyle(color: AppColors.lightPrimary),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: ElevatedButton(
|
||||
onPressed: () async {
|
||||
await _handleResult(
|
||||
RecommendationDialogResult.confirm,
|
||||
);
|
||||
},
|
||||
style: ElevatedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
backgroundColor: AppColors.lightPrimary,
|
||||
foregroundColor: Colors.white,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
child: const Text('닫기'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'조용히 두면 자동으로 방문 처리되고 알림이 예약됩니다.',
|
||||
style: AppTypography.caption(isDark),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -290,7 +290,16 @@ class _ShareScreenState extends ConsumerState<ShareScreen> {
|
||||
}
|
||||
|
||||
Future<void> _generateShareCode() async {
|
||||
final hasPermission =
|
||||
await PermissionService.checkAndRequestBluetoothPermission();
|
||||
if (!hasPermission) {
|
||||
if (!mounted) return;
|
||||
_showErrorSnackBar('블루투스 권한을 허용해야 공유 코드를 생성할 수 있어요.');
|
||||
return;
|
||||
}
|
||||
|
||||
final adService = ref.read(adServiceProvider);
|
||||
if (!mounted) return;
|
||||
final adWatched = await adService.showInterstitialAd(context);
|
||||
if (!mounted) return;
|
||||
if (!adWatched) {
|
||||
@@ -301,12 +310,17 @@ class _ShareScreenState extends ConsumerState<ShareScreen> {
|
||||
final random = Random();
|
||||
final code = List.generate(6, (_) => random.nextInt(10)).join();
|
||||
|
||||
setState(() {
|
||||
_shareCode = code;
|
||||
});
|
||||
|
||||
await ref.read(bluetoothServiceProvider).startListening(code);
|
||||
_showSuccessSnackBar('공유 코드가 생성되었습니다.');
|
||||
try {
|
||||
await ref.read(bluetoothServiceProvider).startListening(code);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_shareCode = code;
|
||||
});
|
||||
_showSuccessSnackBar('공유 코드가 생성되었습니다.');
|
||||
} catch (_) {
|
||||
if (!mounted) return;
|
||||
_showErrorSnackBar('코드를 생성하지 못했습니다. 잠시 후 다시 시도해 주세요.');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _scanDevices() async {
|
||||
|
||||
Reference in New Issue
Block a user