feat(debug): add preview toggles and auto-close ads
- 기록/통계 탭에 디버그 토글 배너 추가 및 테스트 데이터 주입 로직 상태화\n- 리스트 공유 화면에 디버그 프리뷰 토글, 광고 관문, 디버그 전송 흐름 반영\n- 모의 전면 광고는 대기 시간 종료 시 자동 완료되도록 변경\n- AGENTS.md에 코멘트는 한국어로 작성 규칙 명시\n\n테스트: flutter analyze; flutter test
This commit is contained in:
@@ -32,6 +32,7 @@ Never commit API secrets. Instead, create `lib/core/constants/api_keys.dart` loc
|
||||
- 기본 응답은 한국어로 작성하고, 코드/로그/명령어는 원문을 유지합니다.
|
||||
- Business logic, identifiers, and UI strings remain in English, but 주석과 문서 설명은 가능한 한 한국어로 작성하고 처음에는 해당 영어 용어를 괄호로 병기합니다.
|
||||
- Git push 보고나 작업 완료 보고 역시 한국어로 작성합니다.
|
||||
- 코드 주석, 커밋/PR/작업 요약 코멘트도 한국어로 작성하고 필요한 경우 영어 용어만 병기합니다.
|
||||
|
||||
## Validation & Quality Checks
|
||||
- Run `dart format --set-exit-if-changed .` before finishing a task to ensure formatting stays consistent.
|
||||
|
||||
@@ -28,6 +28,7 @@ class _MockInterstitialAdDialogState extends State<_MockInterstitialAdDialog> {
|
||||
|
||||
late Timer _timer;
|
||||
int _elapsedSeconds = 0;
|
||||
bool _completed = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -37,8 +38,10 @@ class _MockInterstitialAdDialogState extends State<_MockInterstitialAdDialog> {
|
||||
setState(() {
|
||||
_elapsedSeconds++;
|
||||
});
|
||||
if (_elapsedSeconds >= _adDurationSeconds) {
|
||||
if (_elapsedSeconds >= _adDurationSeconds && !_completed) {
|
||||
_completed = true;
|
||||
_timer.cancel();
|
||||
Navigator.of(context).pop(true);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -99,26 +102,13 @@ class _MockInterstitialAdDialogState extends State<_MockInterstitialAdDialog> {
|
||||
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),
|
||||
const SizedBox(height: 12),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop(false);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:table_calendar/table_calendar.dart';
|
||||
@@ -6,10 +7,12 @@ 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/debug_test_data_provider.dart';
|
||||
import '../../providers/visit_provider.dart';
|
||||
import 'widgets/visit_record_card.dart';
|
||||
import 'widgets/recommendation_record_card.dart';
|
||||
import 'widgets/visit_statistics.dart';
|
||||
import 'widgets/debug_test_data_banner.dart';
|
||||
|
||||
class CalendarScreen extends ConsumerStatefulWidget {
|
||||
const CalendarScreen({super.key});
|
||||
@@ -32,6 +35,11 @@ class _CalendarScreenState extends ConsumerState<CalendarScreen>
|
||||
_selectedDay = DateTime.now();
|
||||
_focusedDay = DateTime.now();
|
||||
_tabController = TabController(length: 2, vsync: this);
|
||||
if (kDebugMode) {
|
||||
Future.microtask(
|
||||
() => ref.read(debugTestDataNotifierProvider.notifier).initialize(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -100,6 +108,10 @@ class _CalendarScreenState extends ConsumerState<CalendarScreen>
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
if (kDebugMode)
|
||||
const DebugTestDataBanner(
|
||||
margin: EdgeInsets.fromLTRB(16, 16, 16, 8),
|
||||
),
|
||||
// 캘린더
|
||||
Card(
|
||||
margin: const EdgeInsets.all(16),
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
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/presentation/providers/debug_test_data_provider.dart';
|
||||
|
||||
class DebugTestDataBanner extends ConsumerWidget {
|
||||
final EdgeInsetsGeometry? margin;
|
||||
|
||||
const DebugTestDataBanner({super.key, this.margin});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
if (!kDebugMode) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
final state = ref.watch(debugTestDataNotifierProvider);
|
||||
final notifier = ref.read(debugTestDataNotifierProvider.notifier);
|
||||
|
||||
return Card(
|
||||
margin: margin ?? const EdgeInsets.all(16),
|
||||
color: isDark ? AppColors.darkSurface : AppColors.lightSurface,
|
||||
elevation: 1,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.science_outlined,
|
||||
color: AppColors.lightPrimary,
|
||||
size: 20,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'테스트 데이터 미리보기',
|
||||
style: AppTypography.body1(
|
||||
isDark,
|
||||
).copyWith(fontWeight: FontWeight.w600),
|
||||
),
|
||||
const Spacer(),
|
||||
if (state.isProcessing)
|
||||
const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Switch.adaptive(
|
||||
value: state.isEnabled,
|
||||
onChanged: state.isProcessing
|
||||
? null
|
||||
: (value) async {
|
||||
if (value) {
|
||||
await notifier.enableTestData();
|
||||
} else {
|
||||
await notifier.disableTestData();
|
||||
}
|
||||
},
|
||||
activeColor: AppColors.lightPrimary,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
state.isEnabled
|
||||
? '디버그 빌드에서만 적용됩니다. 기록/통계 UI를 테스트용 데이터로 확인하세요.'
|
||||
: '디버그 빌드에서만 사용 가능합니다. 스위치를 켜면 추천·방문 기록이 자동으로 채워집니다.',
|
||||
style: AppTypography.caption(isDark),
|
||||
),
|
||||
if (state.errorMessage != null) ...[
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
state.errorMessage!,
|
||||
style: AppTypography.caption(isDark).copyWith(
|
||||
color: AppColors.lightError,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
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/presentation/providers/visit_provider.dart';
|
||||
import 'package:lunchpick/presentation/providers/restaurant_provider.dart';
|
||||
import 'package:lunchpick/presentation/pages/calendar/widgets/debug_test_data_banner.dart';
|
||||
|
||||
class VisitStatistics extends ConsumerWidget {
|
||||
final DateTime selectedMonth;
|
||||
@@ -32,6 +34,10 @@ class VisitStatistics extends ConsumerWidget {
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
children: [
|
||||
if (kDebugMode) ...[
|
||||
const DebugTestDataBanner(margin: EdgeInsets.zero),
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
// 이번 달 통계
|
||||
_buildMonthlyStats(monthlyStatsAsync, isDark),
|
||||
const SizedBox(height: 16),
|
||||
@@ -130,7 +136,7 @@ class VisitStatistics extends ConsumerWidget {
|
||||
: stats.values.reduce((a, b) => a > b ? a : b);
|
||||
|
||||
return SizedBox(
|
||||
height: 120,
|
||||
height: 140,
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:lunchpick/core/constants/app_colors.dart';
|
||||
@@ -84,6 +85,9 @@ class _ShareScreenState extends ConsumerState<ShareScreen> {
|
||||
List<ShareDevice>? _nearbyDevices;
|
||||
StreamSubscription<String>? _dataSubscription;
|
||||
final _uuid = const Uuid();
|
||||
bool _debugPreviewEnabled = false;
|
||||
bool _debugPreviewProcessing = false;
|
||||
Timer? _debugPreviewTimer;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -98,6 +102,7 @@ class _ShareScreenState extends ConsumerState<ShareScreen> {
|
||||
void dispose() {
|
||||
_dataSubscription?.cancel();
|
||||
ref.read(bluetoothServiceProvider).stopListening();
|
||||
_debugPreviewTimer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -126,6 +131,10 @@ class _ShareScreenState extends ConsumerState<ShareScreen> {
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if (kDebugMode) ...[
|
||||
_buildDebugToggle(isDark),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
_ShareCard(
|
||||
isDark: isDark,
|
||||
icon: Icons.upload_rounded,
|
||||
@@ -294,6 +303,23 @@ class _ShareScreenState extends ConsumerState<ShareScreen> {
|
||||
}
|
||||
|
||||
Future<void> _generateShareCode() async {
|
||||
final adWatched = await ref
|
||||
.read(adServiceProvider)
|
||||
.showInterstitialAd(context);
|
||||
if (!mounted) return;
|
||||
if (!adWatched) {
|
||||
_showErrorSnackBar('광고를 끝까지 시청해야 공유 코드를 생성할 수 있어요.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (kDebugMode && _debugPreviewEnabled) {
|
||||
setState(() {
|
||||
_shareCode = _shareCode ?? _buildDebugShareCode();
|
||||
});
|
||||
_showSuccessSnackBar('디버그 공유 코드가 준비되었습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
final hasPermission =
|
||||
await PermissionService.checkAndRequestBluetoothPermission();
|
||||
if (!hasPermission) {
|
||||
@@ -302,15 +328,6 @@ class _ShareScreenState extends ConsumerState<ShareScreen> {
|
||||
return;
|
||||
}
|
||||
|
||||
final adService = ref.read(adServiceProvider);
|
||||
if (!mounted) return;
|
||||
final adWatched = await adService.showInterstitialAd(context);
|
||||
if (!mounted) return;
|
||||
if (!adWatched) {
|
||||
_showErrorSnackBar('광고를 끝까지 시청해야 공유 코드를 생성할 수 있어요.');
|
||||
return;
|
||||
}
|
||||
|
||||
final random = Random();
|
||||
final code = List.generate(6, (_) => random.nextInt(10)).join();
|
||||
|
||||
@@ -328,6 +345,15 @@ class _ShareScreenState extends ConsumerState<ShareScreen> {
|
||||
}
|
||||
|
||||
Future<void> _scanDevices() async {
|
||||
if (kDebugMode && _debugPreviewEnabled) {
|
||||
setState(() {
|
||||
_isScanning = true;
|
||||
_nearbyDevices = _buildDebugDevices();
|
||||
});
|
||||
_scheduleDebugReceive();
|
||||
return;
|
||||
}
|
||||
|
||||
final hasPermission =
|
||||
await PermissionService.checkAndRequestBluetoothPermission();
|
||||
if (!hasPermission) {
|
||||
@@ -359,6 +385,28 @@ class _ShareScreenState extends ConsumerState<ShareScreen> {
|
||||
}
|
||||
|
||||
Future<void> _sendList(String targetCode) async {
|
||||
final adWatched = await ref
|
||||
.read(adServiceProvider)
|
||||
.showInterstitialAd(context);
|
||||
if (!mounted) return;
|
||||
if (!adWatched) {
|
||||
_showErrorSnackBar('광고를 끝까지 시청해야 리스트를 전송할 수 있어요.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (kDebugMode && _debugPreviewEnabled) {
|
||||
_showLoadingDialog('리스트 전송 중...');
|
||||
await Future<void>.delayed(const Duration(milliseconds: 700));
|
||||
if (!mounted) return;
|
||||
Navigator.pop(context);
|
||||
_showSuccessSnackBar('디버그 전송 완료! (실제 전송 없음)');
|
||||
setState(() {
|
||||
_isScanning = false;
|
||||
_nearbyDevices = null;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
final restaurants = await ref.read(restaurantListProvider.future);
|
||||
if (!mounted) return;
|
||||
|
||||
@@ -381,15 +429,20 @@ class _ShareScreenState extends ConsumerState<ShareScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _handleIncomingData(String payload) async {
|
||||
Future<void> _handleIncomingData(
|
||||
String payload, {
|
||||
bool skipAd = false,
|
||||
}) async {
|
||||
if (!mounted) return;
|
||||
final adWatched = await ref
|
||||
.read(adServiceProvider)
|
||||
.showInterstitialAd(context);
|
||||
if (!mounted) return;
|
||||
if (!adWatched) {
|
||||
_showErrorSnackBar('광고를 시청해야 리스트를 받을 수 있어요.');
|
||||
return;
|
||||
if (!skipAd) {
|
||||
final adWatched = await ref
|
||||
.read(adServiceProvider)
|
||||
.showInterstitialAd(context);
|
||||
if (!mounted) return;
|
||||
if (!adWatched) {
|
||||
_showErrorSnackBar('광고를 시청해야 리스트를 받을 수 있어요.');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -520,4 +573,229 @@ class _ShareScreenState extends ConsumerState<ShareScreen> {
|
||||
SnackBar(content: Text(message), backgroundColor: AppColors.lightError),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDebugToggle(bool isDark) {
|
||||
return Card(
|
||||
color: isDark ? AppColors.darkSurface : AppColors.lightSurface,
|
||||
elevation: 1,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(14),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(10),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.lightPrimary.withOpacity(0.1),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.science_outlined,
|
||||
color: AppColors.lightPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'테스트 토글 (디버그 전용)',
|
||||
style: AppTypography.body1(
|
||||
isDark,
|
||||
).copyWith(fontWeight: FontWeight.w600),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
_debugPreviewEnabled
|
||||
? '샘플 코드·기기와 수신 데이터가 자동으로 표시됩니다.'
|
||||
: '토글을 켜면 광고/권한 없이 공유 UI를 미리 볼 수 있습니다.',
|
||||
style: AppTypography.caption(isDark),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
if (_debugPreviewProcessing)
|
||||
const SizedBox(
|
||||
width: 22,
|
||||
height: 22,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Switch.adaptive(
|
||||
value: _debugPreviewEnabled,
|
||||
onChanged: _debugPreviewProcessing
|
||||
? null
|
||||
: (value) {
|
||||
_toggleDebugPreview(value);
|
||||
},
|
||||
activeColor: AppColors.lightPrimary,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _toggleDebugPreview(bool enabled) async {
|
||||
if (_debugPreviewProcessing) return;
|
||||
setState(() {
|
||||
_debugPreviewProcessing = true;
|
||||
});
|
||||
|
||||
if (enabled) {
|
||||
await _startDebugPreviewFlow();
|
||||
} else {
|
||||
_stopDebugPreviewFlow();
|
||||
}
|
||||
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_debugPreviewEnabled = enabled;
|
||||
_debugPreviewProcessing = false;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _startDebugPreviewFlow() async {
|
||||
_debugPreviewTimer?.cancel();
|
||||
final code = _buildDebugShareCode();
|
||||
setState(() {
|
||||
_shareCode = code;
|
||||
_isScanning = true;
|
||||
_nearbyDevices = _buildDebugDevices();
|
||||
});
|
||||
_scheduleDebugReceive();
|
||||
}
|
||||
|
||||
void _stopDebugPreviewFlow() {
|
||||
_debugPreviewTimer?.cancel();
|
||||
setState(() {
|
||||
_shareCode = null;
|
||||
_isScanning = false;
|
||||
_nearbyDevices = null;
|
||||
});
|
||||
}
|
||||
|
||||
void _scheduleDebugReceive() {
|
||||
_debugPreviewTimer?.cancel();
|
||||
_debugPreviewTimer = Timer(const Duration(seconds: 1), () {
|
||||
if (!mounted || !_debugPreviewEnabled) return;
|
||||
final payload = _buildDebugPayload();
|
||||
_handleIncomingData(payload, skipAd: true);
|
||||
});
|
||||
}
|
||||
|
||||
String _buildDebugShareCode() => 'DBG${Random().nextInt(900000) + 100000}';
|
||||
|
||||
List<ShareDevice> _buildDebugDevices() {
|
||||
final now = DateTime.now();
|
||||
return [
|
||||
ShareDevice(code: 'DBG-ALPHA', deviceId: 'LP-DEBUG-1', discoveredAt: now),
|
||||
ShareDevice(
|
||||
code: 'DBG-BETA',
|
||||
deviceId: 'LP-DEBUG-2',
|
||||
discoveredAt: now.subtract(const Duration(seconds: 10)),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
String _buildDebugPayload() {
|
||||
final samples = _buildDebugRestaurants();
|
||||
final list = samples
|
||||
.map(
|
||||
(restaurant) => {
|
||||
'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,
|
||||
},
|
||||
)
|
||||
.toList();
|
||||
return jsonEncode(list);
|
||||
}
|
||||
|
||||
List<Restaurant> _buildDebugRestaurants() {
|
||||
final now = DateTime.now();
|
||||
return [
|
||||
Restaurant(
|
||||
id: 'debug-share-ramen',
|
||||
name: '디버그 라멘바',
|
||||
category: 'Japanese',
|
||||
subCategory: 'Ramen',
|
||||
description: '테스트용 라멘 바. 실제 전송 없이 미리보기 용도입니다.',
|
||||
phoneNumber: '02-111-1111',
|
||||
roadAddress: '서울 특별시 테스트로 1',
|
||||
jibunAddress: '서울 테스트동 1-1',
|
||||
latitude: 37.566,
|
||||
longitude: 126.9784,
|
||||
lastVisitDate: now.subtract(const Duration(days: 2)),
|
||||
source: DataSource.PRESET,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
naverPlaceId: null,
|
||||
naverUrl: null,
|
||||
businessHours: '11:00 - 21:00',
|
||||
lastVisited: now.subtract(const Duration(days: 2)),
|
||||
visitCount: 3,
|
||||
),
|
||||
Restaurant(
|
||||
id: 'debug-share-burger',
|
||||
name: '샘플 버거샵',
|
||||
category: 'Fastfood',
|
||||
subCategory: 'Burger',
|
||||
description: '광고·권한 없이 교환 흐름을 확인하는 샘플 버거 가게.',
|
||||
phoneNumber: '02-222-2222',
|
||||
roadAddress: '서울 특별시 디버그길 22',
|
||||
jibunAddress: '서울 디버그동 22-2',
|
||||
latitude: 37.57,
|
||||
longitude: 126.982,
|
||||
lastVisitDate: now.subtract(const Duration(days: 5)),
|
||||
source: DataSource.PRESET,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
naverPlaceId: null,
|
||||
naverUrl: null,
|
||||
businessHours: '10:00 - 23:00',
|
||||
lastVisited: now.subtract(const Duration(days: 5)),
|
||||
visitCount: 1,
|
||||
),
|
||||
Restaurant(
|
||||
id: 'debug-share-brunch',
|
||||
name: '프리뷰 브런치 카페',
|
||||
category: 'Cafe',
|
||||
subCategory: 'Brunch',
|
||||
description: '리스트 공유 수신 UI를 확인하기 위한 브런치 카페 샘플.',
|
||||
phoneNumber: '02-333-3333',
|
||||
roadAddress: '서울 특별시 미리보기로 33',
|
||||
jibunAddress: '서울 미리보기동 33-3',
|
||||
latitude: 37.561,
|
||||
longitude: 126.99,
|
||||
lastVisitDate: now.subtract(const Duration(days: 1)),
|
||||
source: DataSource.PRESET,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
naverPlaceId: null,
|
||||
naverUrl: null,
|
||||
businessHours: '09:00 - 18:00',
|
||||
lastVisited: now.subtract(const Duration(days: 1)),
|
||||
visitCount: 4,
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
388
lib/presentation/providers/debug_test_data_provider.dart
Normal file
388
lib/presentation/providers/debug_test_data_provider.dart
Normal file
@@ -0,0 +1,388 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:lunchpick/domain/entities/recommendation_record.dart';
|
||||
import 'package:lunchpick/domain/entities/restaurant.dart';
|
||||
import 'package:lunchpick/domain/entities/visit_record.dart';
|
||||
import 'package:lunchpick/presentation/providers/di_providers.dart';
|
||||
|
||||
class DebugTestDataState {
|
||||
final bool isEnabled;
|
||||
final bool isProcessing;
|
||||
final String? errorMessage;
|
||||
|
||||
const DebugTestDataState({
|
||||
this.isEnabled = false,
|
||||
this.isProcessing = false,
|
||||
this.errorMessage,
|
||||
});
|
||||
|
||||
DebugTestDataState copyWith({
|
||||
bool? isEnabled,
|
||||
bool? isProcessing,
|
||||
String? errorMessage,
|
||||
}) {
|
||||
return DebugTestDataState(
|
||||
isEnabled: isEnabled ?? this.isEnabled,
|
||||
isProcessing: isProcessing ?? this.isProcessing,
|
||||
errorMessage: errorMessage,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class DebugTestDataNotifier extends StateNotifier<DebugTestDataState> {
|
||||
DebugTestDataNotifier(this._ref) : super(const DebugTestDataState());
|
||||
|
||||
final Ref _ref;
|
||||
static const String _idPrefix = 'debug-preview-';
|
||||
|
||||
Future<void> initialize() async {
|
||||
if (state.isProcessing) return;
|
||||
state = state.copyWith(isProcessing: true, errorMessage: null);
|
||||
|
||||
try {
|
||||
final hasDebugData = await _hasExistingDebugData();
|
||||
state = state.copyWith(isEnabled: hasDebugData, isProcessing: false);
|
||||
} catch (e) {
|
||||
state = state.copyWith(isProcessing: false, errorMessage: e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> enableTestData() async {
|
||||
if (state.isProcessing) return;
|
||||
|
||||
state = state.copyWith(isProcessing: true, errorMessage: null);
|
||||
try {
|
||||
await _clearDebugData();
|
||||
await _seedDebugData();
|
||||
state = state.copyWith(isEnabled: true, isProcessing: false);
|
||||
} catch (e) {
|
||||
state = state.copyWith(isProcessing: false, errorMessage: e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> disableTestData() async {
|
||||
if (state.isProcessing) return;
|
||||
|
||||
state = state.copyWith(isProcessing: true, errorMessage: null);
|
||||
try {
|
||||
await _clearDebugData();
|
||||
state = state.copyWith(isEnabled: false, isProcessing: false);
|
||||
} catch (e) {
|
||||
state = state.copyWith(isProcessing: false, errorMessage: e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _seedDebugData() async {
|
||||
final restaurantRepo = _ref.read(restaurantRepositoryProvider);
|
||||
final visitRepo = _ref.read(visitRepositoryProvider);
|
||||
final recommendationRepo = _ref.read(recommendationRepositoryProvider);
|
||||
|
||||
final samples = _buildDebugSamples();
|
||||
|
||||
for (final sample in samples) {
|
||||
await restaurantRepo.addRestaurant(sample.restaurant);
|
||||
}
|
||||
|
||||
for (final sample in samples) {
|
||||
for (final visit in sample.visits) {
|
||||
await visitRepo.addVisitRecord(visit);
|
||||
}
|
||||
for (final reco in sample.recommendations) {
|
||||
await recommendationRepo.addRecommendationRecord(reco);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _clearDebugData() async {
|
||||
final visitRepo = _ref.read(visitRepositoryProvider);
|
||||
final recommendationRepo = _ref.read(recommendationRepositoryProvider);
|
||||
final restaurantRepo = _ref.read(restaurantRepositoryProvider);
|
||||
|
||||
final visits = await visitRepo.getAllVisitRecords();
|
||||
for (final visit in visits.where((v) => _isDebugId(v.id))) {
|
||||
await visitRepo.deleteVisitRecord(visit.id);
|
||||
}
|
||||
|
||||
final recos = await recommendationRepo.getAllRecommendationRecords();
|
||||
for (final reco in recos.where((r) => _isDebugId(r.id))) {
|
||||
await recommendationRepo.deleteRecommendationRecord(reco.id);
|
||||
}
|
||||
|
||||
final restaurants = await restaurantRepo.getAllRestaurants();
|
||||
for (final restaurant in restaurants.where((r) => _isDebugId(r.id))) {
|
||||
await restaurantRepo.deleteRestaurant(restaurant.id);
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> _hasExistingDebugData() async {
|
||||
final visitRepo = _ref.read(visitRepositoryProvider);
|
||||
final recommendationRepo = _ref.read(recommendationRepositoryProvider);
|
||||
final restaurantRepo = _ref.read(restaurantRepositoryProvider);
|
||||
|
||||
final visits = await visitRepo.getAllVisitRecords();
|
||||
final recos = await recommendationRepo.getAllRecommendationRecords();
|
||||
final restaurants = await restaurantRepo.getAllRestaurants();
|
||||
|
||||
return visits.any((v) => _isDebugId(v.id)) ||
|
||||
recos.any((r) => _isDebugId(r.id)) ||
|
||||
restaurants.any((r) => _isDebugId(r.id));
|
||||
}
|
||||
|
||||
List<_DebugSample> _buildDebugSamples() {
|
||||
final today = DateTime.now();
|
||||
final baseDay = DateTime(today.year, today.month, today.day);
|
||||
|
||||
DateTime atDayOffset(int daysAgo, {int hour = 12, int minute = 0}) {
|
||||
return baseDay
|
||||
.subtract(Duration(days: daysAgo))
|
||||
.add(Duration(hours: hour, minutes: minute));
|
||||
}
|
||||
|
||||
VisitRecord buildVisit({
|
||||
required String id,
|
||||
required String restaurantId,
|
||||
required DateTime visitDate,
|
||||
required bool isConfirmed,
|
||||
}) {
|
||||
return VisitRecord(
|
||||
id: id,
|
||||
restaurantId: restaurantId,
|
||||
visitDate: visitDate,
|
||||
isConfirmed: isConfirmed,
|
||||
createdAt: visitDate,
|
||||
);
|
||||
}
|
||||
|
||||
RecommendationRecord buildRecommendation({
|
||||
required String id,
|
||||
required String restaurantId,
|
||||
required DateTime recommendationDate,
|
||||
}) {
|
||||
return RecommendationRecord(
|
||||
id: id,
|
||||
restaurantId: restaurantId,
|
||||
recommendationDate: recommendationDate,
|
||||
visited: false,
|
||||
createdAt: recommendationDate,
|
||||
);
|
||||
}
|
||||
|
||||
Restaurant buildRestaurant({
|
||||
required String id,
|
||||
required String name,
|
||||
required String category,
|
||||
required String subCategory,
|
||||
required String roadAddress,
|
||||
required String jibunAddress,
|
||||
required double latitude,
|
||||
required double longitude,
|
||||
required String description,
|
||||
required String phoneNumber,
|
||||
required List<VisitRecord> visits,
|
||||
}) {
|
||||
final latestVisit = visits
|
||||
.map((v) => v.visitDate)
|
||||
.reduce((a, b) => a.isAfter(b) ? a : b);
|
||||
|
||||
return Restaurant(
|
||||
id: id,
|
||||
name: name,
|
||||
category: category,
|
||||
subCategory: subCategory,
|
||||
description: description,
|
||||
phoneNumber: phoneNumber,
|
||||
roadAddress: roadAddress,
|
||||
jibunAddress: jibunAddress,
|
||||
latitude: latitude,
|
||||
longitude: longitude,
|
||||
lastVisitDate: latestVisit,
|
||||
source: DataSource.PRESET,
|
||||
createdAt: baseDay,
|
||||
updatedAt: baseDay,
|
||||
naverPlaceId: null,
|
||||
naverUrl: null,
|
||||
businessHours: null,
|
||||
lastVisited: latestVisit,
|
||||
visitCount: visits.length,
|
||||
needsAddressVerification: false,
|
||||
);
|
||||
}
|
||||
|
||||
final bistroId = _withPrefix('bistro');
|
||||
final sushiId = _withPrefix('sushi');
|
||||
final coffeeId = _withPrefix('coffee');
|
||||
|
||||
final bistroVisits = [
|
||||
buildVisit(
|
||||
id: _withPrefix('visit-bistro-0'),
|
||||
restaurantId: bistroId,
|
||||
visitDate: atDayOffset(0, hour: 12, minute: 10),
|
||||
isConfirmed: false,
|
||||
),
|
||||
buildVisit(
|
||||
id: _withPrefix('visit-bistro-1'),
|
||||
restaurantId: bistroId,
|
||||
visitDate: atDayOffset(2, hour: 19, minute: 0),
|
||||
isConfirmed: true,
|
||||
),
|
||||
buildVisit(
|
||||
id: _withPrefix('visit-bistro-2'),
|
||||
restaurantId: bistroId,
|
||||
visitDate: atDayOffset(5, hour: 13, minute: 15),
|
||||
isConfirmed: true,
|
||||
),
|
||||
];
|
||||
|
||||
final sushiVisits = [
|
||||
buildVisit(
|
||||
id: _withPrefix('visit-sushi-0'),
|
||||
restaurantId: sushiId,
|
||||
visitDate: atDayOffset(1, hour: 12, minute: 40),
|
||||
isConfirmed: true,
|
||||
),
|
||||
buildVisit(
|
||||
id: _withPrefix('visit-sushi-1'),
|
||||
restaurantId: sushiId,
|
||||
visitDate: atDayOffset(3, hour: 18, minute: 30),
|
||||
isConfirmed: false,
|
||||
),
|
||||
buildVisit(
|
||||
id: _withPrefix('visit-sushi-2'),
|
||||
restaurantId: sushiId,
|
||||
visitDate: atDayOffset(6, hour: 20, minute: 10),
|
||||
isConfirmed: true,
|
||||
),
|
||||
];
|
||||
|
||||
final coffeeVisits = [
|
||||
buildVisit(
|
||||
id: _withPrefix('visit-coffee-0'),
|
||||
restaurantId: coffeeId,
|
||||
visitDate: atDayOffset(2, hour: 9, minute: 30),
|
||||
isConfirmed: true,
|
||||
),
|
||||
buildVisit(
|
||||
id: _withPrefix('visit-coffee-1'),
|
||||
restaurantId: coffeeId,
|
||||
visitDate: atDayOffset(4, hour: 15, minute: 15),
|
||||
isConfirmed: true,
|
||||
),
|
||||
buildVisit(
|
||||
id: _withPrefix('visit-coffee-2'),
|
||||
restaurantId: coffeeId,
|
||||
visitDate: atDayOffset(7, hour: 11, minute: 50),
|
||||
isConfirmed: true,
|
||||
),
|
||||
];
|
||||
|
||||
final samples = <_DebugSample>[
|
||||
_DebugSample(
|
||||
restaurant: buildRestaurant(
|
||||
id: bistroId,
|
||||
name: 'Debug Bistro',
|
||||
category: 'Fusion',
|
||||
subCategory: 'Brunch',
|
||||
description:
|
||||
'Sample data to preview the record and statistics experience.',
|
||||
phoneNumber: '02-100-0001',
|
||||
roadAddress: '서울 테스트로 12',
|
||||
jibunAddress: '서울 테스트동 12-1',
|
||||
latitude: 37.5665,
|
||||
longitude: 126.9780,
|
||||
visits: bistroVisits,
|
||||
),
|
||||
visits: bistroVisits,
|
||||
recommendations: [
|
||||
buildRecommendation(
|
||||
id: _withPrefix('reco-bistro-0'),
|
||||
restaurantId: bistroId,
|
||||
recommendationDate: atDayOffset(1, hour: 11, minute: 20),
|
||||
),
|
||||
buildRecommendation(
|
||||
id: _withPrefix('reco-bistro-1'),
|
||||
restaurantId: bistroId,
|
||||
recommendationDate: atDayOffset(4, hour: 18, minute: 40),
|
||||
),
|
||||
],
|
||||
),
|
||||
_DebugSample(
|
||||
restaurant: buildRestaurant(
|
||||
id: sushiId,
|
||||
name: 'Sample Sushi Bar',
|
||||
category: 'Japanese',
|
||||
subCategory: 'Sushi',
|
||||
description: 'Rotating omakase picks to mimic real visit timelines.',
|
||||
phoneNumber: '02-200-0002',
|
||||
roadAddress: '서울 샘플로 21',
|
||||
jibunAddress: '서울 샘플동 21-3',
|
||||
latitude: 37.5559,
|
||||
longitude: 126.9363,
|
||||
visits: sushiVisits,
|
||||
),
|
||||
visits: sushiVisits,
|
||||
recommendations: [
|
||||
buildRecommendation(
|
||||
id: _withPrefix('reco-sushi-0'),
|
||||
restaurantId: sushiId,
|
||||
recommendationDate: atDayOffset(3, hour: 12, minute: 0),
|
||||
),
|
||||
buildRecommendation(
|
||||
id: _withPrefix('reco-sushi-1'),
|
||||
restaurantId: sushiId,
|
||||
recommendationDate: atDayOffset(7, hour: 19, minute: 10),
|
||||
),
|
||||
],
|
||||
),
|
||||
_DebugSample(
|
||||
restaurant: buildRestaurant(
|
||||
id: coffeeId,
|
||||
name: 'Test Coffee Lab',
|
||||
category: 'Cafe',
|
||||
subCategory: 'Dessert',
|
||||
description: 'Morning cafe stops added so charts render immediately.',
|
||||
phoneNumber: '02-300-0003',
|
||||
roadAddress: '서울 예제길 5',
|
||||
jibunAddress: '서울 예제동 5-2',
|
||||
latitude: 37.5412,
|
||||
longitude: 126.986,
|
||||
visits: coffeeVisits,
|
||||
),
|
||||
visits: coffeeVisits,
|
||||
recommendations: [
|
||||
buildRecommendation(
|
||||
id: _withPrefix('reco-coffee-0'),
|
||||
restaurantId: coffeeId,
|
||||
recommendationDate: atDayOffset(0, hour: 8, minute: 50),
|
||||
),
|
||||
buildRecommendation(
|
||||
id: _withPrefix('reco-coffee-1'),
|
||||
restaurantId: coffeeId,
|
||||
recommendationDate: atDayOffset(5, hour: 16, minute: 30),
|
||||
),
|
||||
],
|
||||
),
|
||||
];
|
||||
|
||||
return samples;
|
||||
}
|
||||
|
||||
bool _isDebugId(String id) => id.startsWith(_idPrefix);
|
||||
|
||||
String _withPrefix(String rawId) => '$_idPrefix$rawId';
|
||||
}
|
||||
|
||||
final debugTestDataNotifierProvider =
|
||||
StateNotifierProvider<DebugTestDataNotifier, DebugTestDataState>((ref) {
|
||||
return DebugTestDataNotifier(ref);
|
||||
});
|
||||
|
||||
class _DebugSample {
|
||||
final Restaurant restaurant;
|
||||
final List<VisitRecord> visits;
|
||||
final List<RecommendationRecord> recommendations;
|
||||
|
||||
_DebugSample({
|
||||
required this.restaurant,
|
||||
required this.visits,
|
||||
required this.recommendations,
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user