diff --git a/AGENTS.md b/AGENTS.md index 86c0d78..fb2d50a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. diff --git a/lib/core/services/ad_service.dart b/lib/core/services/ad_service.dart index e844518..fa83c17 100644 --- a/lib/core/services/ad_service.dart +++ b/lib/core/services/ad_service.dart @@ -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); diff --git a/lib/presentation/pages/calendar/calendar_screen.dart b/lib/presentation/pages/calendar/calendar_screen.dart index e251a2f..3770322 100644 --- a/lib/presentation/pages/calendar/calendar_screen.dart +++ b/lib/presentation/pages/calendar/calendar_screen.dart @@ -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 _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 return Column( children: [ + if (kDebugMode) + const DebugTestDataBanner( + margin: EdgeInsets.fromLTRB(16, 16, 16, 8), + ), // 캘린더 Card( margin: const EdgeInsets.all(16), diff --git a/lib/presentation/pages/calendar/widgets/debug_test_data_banner.dart b/lib/presentation/pages/calendar/widgets/debug_test_data_banner.dart new file mode 100644 index 0000000..7371201 --- /dev/null +++ b/lib/presentation/pages/calendar/widgets/debug_test_data_banner.dart @@ -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, + ), + ), + ], + ], + ), + ), + ); + } +} diff --git a/lib/presentation/pages/calendar/widgets/visit_statistics.dart b/lib/presentation/pages/calendar/widgets/visit_statistics.dart index 9b4ddba..61e7be0 100644 --- a/lib/presentation/pages/calendar/widgets/visit_statistics.dart +++ b/lib/presentation/pages/calendar/widgets/visit_statistics.dart @@ -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, diff --git a/lib/presentation/pages/share/share_screen.dart b/lib/presentation/pages/share/share_screen.dart index 347f8f4..880e0c6 100644 --- a/lib/presentation/pages/share/share_screen.dart +++ b/lib/presentation/pages/share/share_screen.dart @@ -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 { List? _nearbyDevices; StreamSubscription? _dataSubscription; final _uuid = const Uuid(); + bool _debugPreviewEnabled = false; + bool _debugPreviewProcessing = false; + Timer? _debugPreviewTimer; @override void initState() { @@ -98,6 +102,7 @@ class _ShareScreenState extends ConsumerState { void dispose() { _dataSubscription?.cancel(); ref.read(bluetoothServiceProvider).stopListening(); + _debugPreviewTimer?.cancel(); super.dispose(); } @@ -126,6 +131,10 @@ class _ShareScreenState extends ConsumerState { 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 { } Future _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 { 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 { } Future _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 { } Future _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.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 { } } - Future _handleIncomingData(String payload) async { + Future _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 { 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 _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 _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 _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 _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, + ), + ]; + } } diff --git a/lib/presentation/providers/debug_test_data_provider.dart b/lib/presentation/providers/debug_test_data_provider.dart new file mode 100644 index 0000000..e4f5200 --- /dev/null +++ b/lib/presentation/providers/debug_test_data_provider.dart @@ -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 { + DebugTestDataNotifier(this._ref) : super(const DebugTestDataState()); + + final Ref _ref; + static const String _idPrefix = 'debug-preview-'; + + Future 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 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 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 _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 _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 _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 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((ref) { + return DebugTestDataNotifier(ref); + }); + +class _DebugSample { + final Restaurant restaurant; + final List visits; + final List recommendations; + + _DebugSample({ + required this.restaurant, + required this.visits, + required this.recommendations, + }); +}