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:
JiWoong Sul
2025-12-02 15:24:34 +09:00
parent 69902bbc30
commit df4c34194c
7 changed files with 801 additions and 34 deletions

View File

@@ -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.

View File

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

View File

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

View File

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

View File

@@ -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,

View File

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

View 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,
});
}