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:
@@ -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,
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user