From 6f45c7b4569ad04c1d9c867af06d9520185a2d8c Mon Sep 17 00:00:00 2001 From: JiWoong Sul Date: Mon, 12 Jan 2026 15:16:05 +0900 Subject: [PATCH] =?UTF-8?q?perf(app):=20=EC=B4=88=EA=B8=B0=ED=99=94=20?= =?UTF-8?q?=EB=B3=91=EB=A0=AC=20=EC=B2=98=EB=A6=AC=20=EB=B0=8F=20UI=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 성능 최적화 ### main.dart - 앱 초기화 병렬 처리 (Future.wait 활용) - 광고 SDK, Hive 초기화 동시 실행 - Hive Box 오픈 병렬 처리 - 코드 구조화 (_initializeHive, _initializeNotifications) ### visit_provider.dart - allLastVisitDatesProvider 추가 - 리스트 화면에서 N+1 쿼리 방지 - 모든 맛집의 마지막 방문일 일괄 조회 ## UI 개선 ### 각 화면 리팩토링 - AppDimensions 상수 적용 - 스켈레톤 로더 적용 - 코드 정리 및 일관성 개선 --- lib/main.dart | 57 ++++++---- .../pages/calendar/calendar_screen.dart | 80 +++++++------ .../random_selection_screen.dart | 13 ++- .../restaurant_list_screen.dart | 89 ++++++++++----- .../widgets/restaurant_card.dart | 107 ++++++++---------- .../pages/splash/splash_screen.dart | 78 +++++-------- .../providers/visit_provider.dart | 17 +++ .../widgets/native_ad_placeholder.dart | 16 ++- 8 files changed, 252 insertions(+), 205 deletions(-) diff --git a/lib/main.dart b/lib/main.dart index ce11dec..4afa540 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -22,14 +22,28 @@ import 'data/sample/sample_data_initializer.dart'; void main() async { WidgetsFlutterBinding.ensureInitialized(); - if (AdHelper.isMobilePlatform) { - await MobileAds.instance.initialize(); - } - - // Initialize timezone + // Initialize timezone (동기, 빠름) tz.initializeTimeZones(); - // Initialize Hive + // 광고 SDK와 Hive 초기화를 병렬 처리 + await Future.wait([ + if (AdHelper.isMobilePlatform) MobileAds.instance.initialize(), + _initializeHive(), + ]); + + // Hive 초기화 후 병렬 처리 가능한 작업들 + await Future.wait([ + SampleDataInitializer.seedInitialData(), + _initializeNotifications(), + AdaptiveTheme.getThemeMode(), + ]).then((results) { + final savedThemeMode = results[2] as AdaptiveThemeMode?; + runApp(ProviderScope(child: LunchPickApp(savedThemeMode: savedThemeMode))); + }); +} + +/// Hive 초기화 및 Box 오픈 +Future _initializeHive() async { await Hive.initFlutter(); // Register Hive Adapters @@ -39,24 +53,21 @@ void main() async { Hive.registerAdapter(RecommendationRecordAdapter()); Hive.registerAdapter(UserSettingsAdapter()); - // Open Hive Boxes - await Hive.openBox(AppConstants.restaurantBox); - await Hive.openBox(AppConstants.visitRecordBox); - await Hive.openBox(AppConstants.recommendationBox); - await Hive.openBox(AppConstants.settingsBox); - await Hive.openBox('user_settings'); - await SampleDataInitializer.seedInitialData(); + // Open Hive Boxes (병렬 오픈) + await Future.wait([ + Hive.openBox(AppConstants.restaurantBox), + Hive.openBox(AppConstants.visitRecordBox), + Hive.openBox(AppConstants.recommendationBox), + Hive.openBox(AppConstants.settingsBox), + Hive.openBox('user_settings'), + ]); +} - // Initialize Notification Service (only for non-web platforms) - if (!kIsWeb) { - final notificationService = NotificationService(); - await notificationService.ensureInitialized(requestPermission: true); - } - - // Get saved theme mode - final savedThemeMode = await AdaptiveTheme.getThemeMode(); - - runApp(ProviderScope(child: LunchPickApp(savedThemeMode: savedThemeMode))); +/// 알림 서비스 초기화 (비-웹 플랫폼) +Future _initializeNotifications() async { + if (kIsWeb) return; + final notificationService = NotificationService(); + await notificationService.ensureInitialized(requestPermission: true); } class LunchPickApp extends StatelessWidget { diff --git a/lib/presentation/pages/calendar/calendar_screen.dart b/lib/presentation/pages/calendar/calendar_screen.dart index b0126fe..aaa9153 100644 --- a/lib/presentation/pages/calendar/calendar_screen.dart +++ b/lib/presentation/pages/calendar/calendar_screen.dart @@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:table_calendar/table_calendar.dart'; import '../../../core/constants/app_colors.dart'; import '../../../core/constants/app_typography.dart'; +import '../../../core/widgets/info_row.dart'; import '../../../domain/entities/restaurant.dart'; import '../../../domain/entities/recommendation_record.dart'; import '../../../domain/entities/visit_record.dart'; @@ -607,18 +608,25 @@ class _CalendarScreenState extends ConsumerState ), ), const SizedBox(height: 12), - _buildInfoRow('주소', restaurant.roadAddress, isDark), + InfoRow( + label: '주소', + value: restaurant.roadAddress, + isDark: isDark, + horizontal: true, + ), if (visitTime != null) - _buildInfoRow( - isVisitConfirmed ? '방문 완료' : '방문 예정', - _formatFullDateTime(visitTime), - isDark, + InfoRow( + label: isVisitConfirmed ? '방문 완료' : '방문 예정', + value: _formatFullDateTime(visitTime), + isDark: isDark, + horizontal: true, ), if (recoTime != null) - _buildInfoRow( - '추천 시각', - _formatFullDateTime(recoTime), - isDark, + InfoRow( + label: '추천 시각', + value: _formatFullDateTime(recoTime), + isDark: isDark, + horizontal: true, ), const SizedBox(height: 12), Wrap( @@ -703,23 +711,6 @@ class _CalendarScreenState extends ConsumerState ); } - Widget _buildInfoRow(String label, String value, bool isDark) { - return Padding( - padding: const EdgeInsets.symmetric(vertical: 4), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SizedBox( - width: 80, - child: Text(label, style: AppTypography.caption(isDark)), - ), - const SizedBox(width: 8), - Expanded(child: Text(value, style: AppTypography.body2(isDark))), - ], - ), - ); - } - Future _showRestaurantDetailDialog( BuildContext context, bool isDark, @@ -737,18 +728,39 @@ class _CalendarScreenState extends ConsumerState mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - _buildInfoRow( - '카테고리', - '${restaurant.category} > ${restaurant.subCategory}', - isDark, + InfoRow( + label: '카테고리', + value: '${restaurant.category} > ${restaurant.subCategory}', + isDark: isDark, + horizontal: true, ), if (restaurant.phoneNumber != null) - _buildInfoRow('전화번호', restaurant.phoneNumber!, isDark), - _buildInfoRow('도로명', restaurant.roadAddress, isDark), - _buildInfoRow('지번', restaurant.jibunAddress, isDark), + InfoRow( + label: '전화번호', + value: restaurant.phoneNumber!, + isDark: isDark, + horizontal: true, + ), + InfoRow( + label: '도로명', + value: restaurant.roadAddress, + isDark: isDark, + horizontal: true, + ), + InfoRow( + label: '지번', + value: restaurant.jibunAddress, + isDark: isDark, + horizontal: true, + ), if (restaurant.description != null && restaurant.description!.isNotEmpty) - _buildInfoRow('메모', restaurant.description!, isDark), + InfoRow( + label: '메모', + value: restaurant.description!, + isDark: isDark, + horizontal: true, + ), ], ), ), diff --git a/lib/presentation/pages/random_selection/random_selection_screen.dart b/lib/presentation/pages/random_selection/random_selection_screen.dart index 053c437..e5a4616 100644 --- a/lib/presentation/pages/random_selection/random_selection_screen.dart +++ b/lib/presentation/pages/random_selection/random_selection_screen.dart @@ -1,9 +1,11 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:geolocator/geolocator.dart'; import 'package:go_router/go_router.dart'; import '../../../core/constants/app_colors.dart'; +import '../../../core/constants/app_dimensions.dart'; import '../../../core/constants/app_typography.dart'; import '../../../core/utils/category_mapper.dart'; import '../../../domain/entities/restaurant.dart'; @@ -306,8 +308,8 @@ class _RandomSelectionScreenState extends ConsumerState { child: Slider( value: _distanceValue, min: 100, - max: 2000, - divisions: 19, + max: AppDimensions.maxSearchDistance, + divisions: AppDimensions.distanceSliderDivisions, onChanged: (value) { setState(() => _distanceValue = value); }, @@ -739,6 +741,9 @@ class _RandomSelectionScreenState extends ConsumerState { }) async { if (_isProcessingRecommendation) return; + // 버튼 터치 햅틱 피드백 + HapticFeedback.mediumImpact(); + if (!isReroll) { _excludedRestaurantIds.clear(); } @@ -769,6 +774,10 @@ class _RandomSelectionScreenState extends ConsumerState { } if (!mounted) return; + + // 추천 결과 햅틱 피드백 + HapticFeedback.heavyImpact(); + await _showRecommendationDialog(candidate, recommendedAt: recommendedAt); } catch (_) { _showSnack('추천을 준비하는 중 문제가 발생했습니다.', type: _SnackType.error); diff --git a/lib/presentation/pages/restaurant_list/restaurant_list_screen.dart b/lib/presentation/pages/restaurant_list/restaurant_list_screen.dart index c234fdd..97bef5d 100644 --- a/lib/presentation/pages/restaurant_list/restaurant_list_screen.dart +++ b/lib/presentation/pages/restaurant_list/restaurant_list_screen.dart @@ -1,11 +1,14 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../core/constants/app_colors.dart'; +import '../../../core/constants/app_dimensions.dart'; import '../../../core/constants/app_typography.dart'; +import '../../../core/widgets/skeleton_loader.dart'; import '../../../core/utils/category_mapper.dart'; import '../../../core/utils/app_logger.dart'; import '../../providers/restaurant_provider.dart'; import '../../providers/settings_provider.dart'; +import '../../providers/visit_provider.dart'; import '../../widgets/category_selector.dart'; import '../../widgets/native_ad_placeholder.dart'; import 'manual_restaurant_input_screen.dart'; @@ -40,6 +43,8 @@ class _RestaurantListScreenState extends ConsumerState { .maybeWhen(data: (value) => value, orElse: () => false); final isFiltered = searchQuery.isNotEmpty || selectedCategory != null; final restaurantsAsync = ref.watch(sortedRestaurantsByDistanceProvider); + final lastVisitDates = + ref.watch(allLastVisitDatesProvider).valueOrNull ?? {}; return Scaffold( backgroundColor: isDark @@ -70,6 +75,7 @@ class _RestaurantListScreenState extends ConsumerState { if (_isSearching) ...[ IconButton( icon: const Icon(Icons.close), + tooltip: '검색 닫기', onPressed: () { setState(() { _isSearching = false; @@ -81,6 +87,7 @@ class _RestaurantListScreenState extends ConsumerState { ] else ...[ IconButton( icon: const Icon(Icons.search), + tooltip: '맛집 검색', onPressed: () { setState(() { _isSearching = true; @@ -149,8 +156,8 @@ class _RestaurantListScreenState extends ConsumerState { return _buildEmptyState(isDark); } - const adInterval = 6; // 5리스트 후 1광고 - const adOffset = 5; // 1~5 리스트 이후 6 광고 시작 + const adInterval = AppDimensions.adInterval; + const adOffset = AppDimensions.adOffset; final adCount = (items.length ~/ adOffset); final totalCount = items.length + adCount; @@ -180,48 +187,70 @@ class _RestaurantListScreenState extends ConsumerState { return RestaurantCard( restaurant: item.restaurant, distanceKm: item.distanceKm, + lastVisitDate: lastVisitDates[item.restaurant.id], ); }, ); }, loading: () { AppLogger.debug('[restaurant_list_ui] loading...'); - return const Center( - child: CircularProgressIndicator( - color: AppColors.lightPrimary, - ), - ); + return const RestaurantListSkeleton(); }, error: (error, stack) => Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - Icons.error_outline, - size: 64, - color: isDark - ? AppColors.darkError - : AppColors.lightError, - ), - const SizedBox(height: 16), - Text('오류가 발생했습니다', style: AppTypography.heading2(isDark)), - const SizedBox(height: 8), - Text( - error.toString(), - style: AppTypography.body2(isDark), - textAlign: TextAlign.center, - ), - ], + child: Padding( + padding: const EdgeInsets.all(AppDimensions.paddingDefault), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.error_outline, + size: AppDimensions.iconXxl, + color: isDark + ? AppColors.darkError + : AppColors.lightError, + ), + const SizedBox(height: AppDimensions.paddingDefault), + Text('오류가 발생했습니다', style: AppTypography.heading2(isDark)), + const SizedBox(height: AppDimensions.paddingSm), + Text( + error.toString(), + style: AppTypography.body2(isDark), + textAlign: TextAlign.center, + maxLines: 3, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: AppDimensions.paddingLg), + ElevatedButton.icon( + onPressed: () => + ref.invalidate(sortedRestaurantsByDistanceProvider), + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.lightPrimary, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric( + horizontal: AppDimensions.paddingXl, + vertical: AppDimensions.paddingMd, + ), + ), + icon: const Icon(Icons.refresh), + label: const Text('다시 시도'), + ), + ], + ), ), ), ), ), ], ), - floatingActionButton: FloatingActionButton( - onPressed: _showAddOptions, - backgroundColor: AppColors.lightPrimary, - child: const Icon(Icons.add, color: Colors.white), + floatingActionButton: Semantics( + label: '맛집 추가하기', + button: true, + child: FloatingActionButton( + onPressed: _showAddOptions, + tooltip: '맛집 추가', + backgroundColor: AppColors.lightPrimary, + child: const Icon(Icons.add, color: Colors.white), + ), ), ); } diff --git a/lib/presentation/pages/restaurant_list/widgets/restaurant_card.dart b/lib/presentation/pages/restaurant_list/widgets/restaurant_card.dart index b1fdf3c..3fe483f 100644 --- a/lib/presentation/pages/restaurant_list/widgets/restaurant_card.dart +++ b/lib/presentation/pages/restaurant_list/widgets/restaurant_card.dart @@ -2,21 +2,28 @@ 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/core/widgets/info_row.dart'; import 'package:lunchpick/domain/entities/restaurant.dart'; import 'package:lunchpick/presentation/providers/restaurant_provider.dart'; -import 'package:lunchpick/presentation/providers/visit_provider.dart'; import 'edit_restaurant_dialog.dart'; +/// 맛집 카드 위젯 +/// [lastVisitDate]를 외부에서 주입받아 리스트 렌더링 최적화 class RestaurantCard extends ConsumerWidget { final Restaurant restaurant; final double? distanceKm; + final DateTime? lastVisitDate; - const RestaurantCard({super.key, required this.restaurant, this.distanceKm}); + const RestaurantCard({ + super.key, + required this.restaurant, + this.distanceKm, + this.lastVisitDate, + }); @override Widget build(BuildContext context, WidgetRef ref) { final isDark = Theme.of(context).brightness == Brightness.dark; - final lastVisitAsync = ref.watch(lastVisitDateProvider(restaurant.id)); return Card( margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), @@ -177,39 +184,26 @@ class RestaurantCard extends ConsumerWidget { ), // 마지막 방문일 - lastVisitAsync.when( - data: (lastVisit) { - if (lastVisit != null) { - final daysSinceVisit = DateTime.now() - .difference(lastVisit) - .inDays; - return Padding( - padding: const EdgeInsets.only(top: 8), - child: Row( - children: [ - Icon( - Icons.schedule, - size: 16, - color: isDark - ? AppColors.darkTextSecondary - : AppColors.lightTextSecondary, - ), - const SizedBox(width: 4), - Text( - daysSinceVisit == 0 - ? '오늘 방문' - : '$daysSinceVisit일 전 방문', - style: AppTypography.caption(isDark), - ), - ], + if (lastVisitDate != null) + Padding( + padding: const EdgeInsets.only(top: 8), + child: Row( + children: [ + Icon( + Icons.schedule, + size: 16, + color: isDark + ? AppColors.darkTextSecondary + : AppColors.lightTextSecondary, ), - ); - } - return const SizedBox.shrink(); - }, - loading: () => const SizedBox.shrink(), - error: (_, __) => const SizedBox.shrink(), - ), + const SizedBox(width: 4), + Text( + _formatLastVisit(lastVisitDate!), + style: AppTypography.caption(isDark), + ), + ], + ), + ), ], ), ), @@ -240,6 +234,11 @@ class RestaurantCard extends ConsumerWidget { } } + String _formatLastVisit(DateTime date) { + final daysSinceVisit = DateTime.now().difference(date).inDays; + return daysSinceVisit == 0 ? '오늘 방문' : '$daysSinceVisit일 전 방문'; + } + void _showRestaurantDetail(BuildContext context, bool isDark) { showDialog( context: context, @@ -252,22 +251,22 @@ class RestaurantCard extends ConsumerWidget { mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - _buildDetailRow( - '카테고리', - '${restaurant.category} > ${restaurant.subCategory}', - isDark, + InfoRow( + label: '카테고리', + value: '${restaurant.category} > ${restaurant.subCategory}', + isDark: isDark, ), if (restaurant.description != null) - _buildDetailRow('설명', restaurant.description!, isDark), + InfoRow(label: '설명', value: restaurant.description!, isDark: isDark), if (restaurant.phoneNumber != null) - _buildDetailRow('전화번호', restaurant.phoneNumber!, isDark), - _buildDetailRow('도로명 주소', restaurant.roadAddress, isDark), - _buildDetailRow('지번 주소', restaurant.jibunAddress, isDark), + InfoRow(label: '전화번호', value: restaurant.phoneNumber!, isDark: isDark), + InfoRow(label: '도로명 주소', value: restaurant.roadAddress, isDark: isDark), + InfoRow(label: '지번 주소', value: restaurant.jibunAddress, isDark: isDark), if (restaurant.lastVisitDate != null) - _buildDetailRow( - '마지막 방문', - '${restaurant.lastVisitDate!.year}년 ${restaurant.lastVisitDate!.month}월 ${restaurant.lastVisitDate!.day}일', - isDark, + InfoRow( + label: '마지막 방문', + value: '${restaurant.lastVisitDate!.year}년 ${restaurant.lastVisitDate!.month}월 ${restaurant.lastVisitDate!.day}일', + isDark: isDark, ), ], ), @@ -281,20 +280,6 @@ class RestaurantCard extends ConsumerWidget { ); } - Widget _buildDetailRow(String label, String value, bool isDark) { - return Padding( - padding: const EdgeInsets.symmetric(vertical: 4), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(label, style: AppTypography.caption(isDark)), - const SizedBox(height: 2), - Text(value, style: AppTypography.body2(isDark)), - ], - ), - ); - } - void _handleMenuAction( _RestaurantMenuAction action, BuildContext context, diff --git a/lib/presentation/pages/splash/splash_screen.dart b/lib/presentation/pages/splash/splash_screen.dart index 931561e..575a39a 100644 --- a/lib/presentation/pages/splash/splash_screen.dart +++ b/lib/presentation/pages/splash/splash_screen.dart @@ -15,10 +15,8 @@ class SplashScreen extends StatefulWidget { } class _SplashScreenState extends State - with TickerProviderStateMixin { - late List _foodControllers; - late AnimationController _questionMarkController; - late AnimationController _centerIconController; + with SingleTickerProviderStateMixin { + late AnimationController _animationController; List? _iconPositions; Size? _lastScreenSize; @@ -42,24 +40,9 @@ class _SplashScreenState extends State } void _initializeAnimations() { - // 음식 아이콘 애니메이션 (여러 개) - _foodControllers = List.generate( - foodIcons.length, - (index) => AnimationController( - duration: Duration(seconds: 2 + index % 3), - vsync: this, - )..repeat(reverse: true), - ); - - // 물음표 애니메이션 - _questionMarkController = AnimationController( - duration: const Duration(milliseconds: 500), - vsync: this, - )..repeat(); - - // 중앙 아이콘 애니메이션 - _centerIconController = AnimationController( - duration: const Duration(seconds: 1), + // 단일 컨트롤러로 모든 애니메이션 제어 (메모리 최적화) + _animationController = AnimationController( + duration: const Duration(seconds: 2), vsync: this, )..repeat(reverse: true); } @@ -142,9 +125,9 @@ class _SplashScreenState extends State children: [ // 선택 아이콘 ScaleTransition( - scale: Tween(begin: 0.8, end: 1.2).animate( + scale: Tween(begin: 0.9, end: 1.1).animate( CurvedAnimation( - parent: _centerIconController, + parent: _animationController, curve: Curves.easeInOut, ), ), @@ -164,11 +147,11 @@ class _SplashScreenState extends State children: [ Text('오늘 뭐 먹Z', style: AppTypography.heading1(isDark)), AnimatedBuilder( - animation: _questionMarkController, + animation: _animationController, builder: (context, child) { final questionMarks = '?' * - (((_questionMarkController.value * 3).floor() % 3) + + (((_animationController.value * 3).floor() % 3) + 1); return Text( questionMarks, @@ -217,29 +200,30 @@ class _SplashScreenState extends State return List.generate(foodIcons.length, (index) { final position = _iconPositions![index]; + // 각 아이콘마다 위상(phase)을 다르게 적용 + final phase = index / foodIcons.length; return Positioned( left: position.dx, top: position.dy, - child: FadeTransition( - opacity: Tween(begin: 0.2, end: 0.8).animate( - CurvedAnimation( - parent: _foodControllers[index], - curve: Curves.easeInOut, - ), - ), - child: ScaleTransition( - scale: Tween(begin: 0.5, end: 1.5).animate( - CurvedAnimation( - parent: _foodControllers[index], - curve: Curves.easeInOut, + child: AnimatedBuilder( + animation: _animationController, + builder: (context, child) { + // 위상 차이로 각 아이콘이 다른 타이밍에 애니메이션 + final value = + ((_animationController.value + phase) % 1.0 - 0.5).abs() * 2; + return Opacity( + opacity: 0.2 + value * 0.4, + child: Transform.scale( + scale: 0.7 + value * 0.5, + child: child, ), - ), - child: Icon( - foodIcons[index], - size: 40, - color: AppColors.lightPrimary.withOpacity(0.3), - ), + ); + }, + child: Icon( + foodIcons[index], + size: 40, + color: AppColors.lightPrimary.withValues(alpha: 0.3), ), ), ); @@ -274,11 +258,7 @@ class _SplashScreenState extends State @override void dispose() { - for (final controller in _foodControllers) { - controller.dispose(); - } - _questionMarkController.dispose(); - _centerIconController.dispose(); + _animationController.dispose(); super.dispose(); } } diff --git a/lib/presentation/providers/visit_provider.dart b/lib/presentation/providers/visit_provider.dart index 46624b9..b413b0b 100644 --- a/lib/presentation/providers/visit_provider.dart +++ b/lib/presentation/providers/visit_provider.dart @@ -157,6 +157,23 @@ final lastVisitDateProvider = FutureProvider.family(( return repository.getLastVisitDate(restaurantId); }); +/// 모든 맛집의 마지막 방문일을 한 번에 조회 (리스트 최적화용) +final allLastVisitDatesProvider = + FutureProvider>((ref) async { + final records = await ref.watch(visitRecordsProvider.future); + + // restaurantId별 가장 최근 방문일 계산 + final lastVisitMap = {}; + for (final record in records) { + final existing = lastVisitMap[record.restaurantId]; + if (existing == null || record.visitDate.isAfter(existing)) { + lastVisitMap[record.restaurantId] = record.visitDate; + } + } + + return lastVisitMap; +}); + /// 기간별 방문 기록 Provider final visitRecordsByPeriodProvider = FutureProvider.family< diff --git a/lib/presentation/widgets/native_ad_placeholder.dart b/lib/presentation/widgets/native_ad_placeholder.dart index 6bafe00..edc9e6c 100644 --- a/lib/presentation/widgets/native_ad_placeholder.dart +++ b/lib/presentation/widgets/native_ad_placeholder.dart @@ -144,19 +144,22 @@ class _NativeAdPlaceholderState extends State { final isDark = Theme.of(context).brightness == Brightness.dark; return NativeTemplateStyle( templateType: templateType, - mainBackgroundColor: isDark ? AppColors.darkSurface : Colors.white, + mainBackgroundColor: + isDark ? AppColors.darkSurface : AppColors.lightSurface, cornerRadius: 0, callToActionTextStyle: NativeTemplateTextStyle( - textColor: Colors.white, + textColor: AppColors.lightSurface, backgroundColor: AppColors.lightPrimary, style: NativeTemplateFontStyle.bold, ), primaryTextStyle: NativeTemplateTextStyle( - textColor: isDark ? Colors.white : Colors.black87, + textColor: + isDark ? AppColors.darkTextPrimary : AppColors.lightTextPrimary, style: NativeTemplateFontStyle.bold, ), secondaryTextStyle: NativeTemplateTextStyle( - textColor: isDark ? Colors.white70 : Colors.black54, + textColor: + isDark ? AppColors.darkTextSecondary : AppColors.lightTextSecondary, style: NativeTemplateFontStyle.normal, ), ); @@ -250,14 +253,15 @@ class _NativeAdPlaceholderState extends State { BoxDecoration _decoration(bool isDark) { return BoxDecoration( - color: isDark ? AppColors.darkSurface : Colors.white, + color: isDark ? AppColors.darkSurface : AppColors.lightSurface, border: Border.all( color: isDark ? AppColors.darkDivider : AppColors.lightDivider, width: 2, ), boxShadow: [ BoxShadow( - color: (isDark ? Colors.black : Colors.grey).withOpacity(0.08), + color: (isDark ? AppColors.darkBackground : AppColors.lightDivider) + .withValues(alpha: 0.3), blurRadius: 8, offset: const Offset(0, 4), ),