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:
JiWoong Sul
2025-11-22 00:10:51 +09:00
parent 947fe59486
commit 2a01fa50c6
43 changed files with 1777 additions and 571 deletions

View File

@@ -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,
),
],
),
),
),
],
],
),
),
),
);