From e3e701d056e7f0661b04363d0c7e80ded7ed1971 Mon Sep 17 00:00:00 2001 From: Ju Date: Fri, 15 May 2026 12:07:45 +0900 Subject: [PATCH 01/17] =?UTF-8?q?fix:=20=EC=98=A8=EB=B3=B4=EB=94=A9=20?= =?UTF-8?q?=EB=8B=A8=EA=B3=84=20=EC=A0=80=EC=9E=A5=EB=90=98=EC=A7=80=20?= =?UTF-8?q?=EC=95=8A=EB=8D=98=20=EC=9D=B4=EC=8A=88=20=EC=88=98=EC=A0=95,?= =?UTF-8?q?=20=EC=95=84=EC=9D=B4=ED=8C=A8=EB=93=9C=20=EC=A7=80=EC=9B=90=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ios/Runner.xcodeproj/project.pbxproj | 15 ++ ios/Runner/Info.plist | 243 +++++++++--------- lib/core/config/prefs.dart | 18 ++ lib/features/auth/services/auth_service.dart | 13 +- lib/features/login/login_page.dart | 43 +++- .../onboarding/views/keywords_page.dart | 5 +- .../onboarding/views/nickname_input_page.dart | 4 + .../views/terms_agreement_page.dart | 8 + lib/features/splash/views/splash_view.dart | 12 +- lib/routes/route_params.dart | 4 + 10 files changed, 226 insertions(+), 139 deletions(-) diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 4453519..a50bcc5 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -520,8 +520,13 @@ ); PRODUCT_BUNDLE_IDENTIFIER = com.arttrip.ios; PRODUCT_NAME = "$(TARGET_NAME)"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 1; VERSIONING_SYSTEM = "apple-generic"; }; name = Profile; @@ -706,9 +711,14 @@ ); PRODUCT_BUNDLE_IDENTIFIER = com.arttrip.ios; PRODUCT_NAME = "$(TARGET_NAME)"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 1; VERSIONING_SYSTEM = "apple-generic"; }; name = Debug; @@ -730,8 +740,13 @@ ); PRODUCT_BUNDLE_IDENTIFIER = com.arttrip.ios; PRODUCT_NAME = "$(TARGET_NAME)"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 1; VERSIONING_SYSTEM = "apple-generic"; }; name = Release; diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index 1ef7314..ded085d 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -1,130 +1,127 @@ + + CADisableMinimumFrameDurationOnPhone + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + ArtTrip + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + arttrip + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleSignature + ???? + CFBundleURLTypes + + + CFBundleTypeRole + Editor + CFBundleURLSchemes + + kakao295035084c1e08b86ef4d6a4e107c4fa + + + + CFBundleTypeRole + Editor + CFBundleURLSchemes + + $(GOOGLE_REVERSED_CLIENT_ID) + + + + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + FirebaseAppDelegateProxyEnabled + + GIDClientID + $(GOOGLE_CLIENT_ID) + GoogleMapsApiKey + $(GOOGLE_MAPS_API_KEY) + LSApplicationQueriesSchemes + + kakaokompassauth + kakaolink + kakaoplus + comgooglemaps + + LSRequiresIPhoneOS + + NSCameraUsageDescription + 사진을 촬영하기 위해 카메라 접근 권한이 필요합니다. + NSLocalNetworkUsageDescription + 앱의 디버깅 및 알림 처리를 위해 로컬 네트워크 접근 권한이 필요합니다. + NSLocalNotificationUsageDescription + 중요 공지사항 정보를 놓치지 않기 위해 알림 권한이 필요합니다. + NSLocationAlwaysAndWhenInUseUsageDescription + 현재 위치를 기반으로 주변 전시를 찾기 위해 위치 정보가 필요합니다. + NSLocationWhenInUseUsageDescription + 현재 위치를 기반으로 주변 전시를 찾기 위해 위치 정보가 필요합니다. + NSPhotoLibraryUsageDescription + 사진을 첨부하기 위해 사진 라이브러리 접근 권한이 필요합니다. + NSPushNotificationUsageDescription + 앱에서 알림을 보내기 위해 사용됩니다. + UIApplicationSceneManifest - CFBundleDevelopmentRegion - $(DEVELOPMENT_LANGUAGE) - CFBundleDisplayName - ArtTrip - CFBundleExecutable - $(EXECUTABLE_NAME) - CFBundleIdentifier - $(PRODUCT_BUNDLE_IDENTIFIER) - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - arttrip - CFBundlePackageType - APPL - CFBundleShortVersionString - $(FLUTTER_BUILD_NAME) - CFBundleSignature - ???? - CFBundleVersion - $(FLUTTER_BUILD_NUMBER) - LSRequiresIPhoneOS - - UILaunchStoryboardName - LaunchScreen - UIMainStoryboardFile - Main - UISupportedInterfaceOrientations - - UIInterfaceOrientationPortrait - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - - UISupportedInterfaceOrientations~ipad - - UIInterfaceOrientationPortrait - UIInterfaceOrientationPortraitUpsideDown - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - - CADisableMinimumFrameDurationOnPhone - - UIApplicationSupportsIndirectInputEvents - - UIStatusBarHidden - - UIViewControllerBasedStatusBarAppearance - - LSApplicationQueriesSchemes - - kakaokompassauth - kakaolink - kakaoplus - comgooglemaps - - CFBundleURLTypes - - - CFBundleTypeRole - Editor - CFBundleURLSchemes - - kakao295035084c1e08b86ef4d6a4e107c4fa - - - - CFBundleTypeRole - Editor - CFBundleURLSchemes - - $(GOOGLE_REVERSED_CLIENT_ID) - - - - NSLocationWhenInUseUsageDescription - 현재 위치를 기반으로 주변 전시를 찾기 위해 위치 정보가 필요합니다. - NSPhotoLibraryUsageDescription - 사진을 첨부하기 위해 사진 라이브러리 접근 권한이 필요합니다. - NSCameraUsageDescription - 사진을 촬영하기 위해 카메라 접근 권한이 필요합니다. - GoogleMapsApiKey - $(GOOGLE_MAPS_API_KEY) - GIDClientID - $(GOOGLE_CLIENT_ID) - - UIStatusBarStyle - UIStatusBarStyleDarkContent - - UIBackgroundModes - - remote-notification - processing - fetch - - NSLocalNotificationUsageDescription - 중요 공지사항 정보를 놓치지 않기 위해 알림 권한이 필요합니다. - FirebaseAppDelegateProxyEnabled - - NSPushNotificationUsageDescription - 앱에서 알림을 보내기 위해 사용됩니다. - NSLocalNetworkUsageDescription - 앱의 디버깅 및 알림 처리를 위해 로컬 네트워크 접근 권한이 필요합니다. - - UIApplicationSceneManifest + UIApplicationSupportsMultipleScenes + + UISceneConfigurations - UIApplicationSupportsMultipleScenes - - UISceneConfigurations - - UIWindowSceneSessionRoleApplication - - - UISceneClassName - UIWindowScene - UISceneDelegateClassName - FlutterSceneDelegate - UISceneConfigurationName - flutter - UISceneStoryboardFile - Main - - - + UIWindowSceneSessionRoleApplication + + + UISceneClassName + UIWindowScene + UISceneConfigurationName + flutter + UISceneDelegateClassName + FlutterSceneDelegate + UISceneStoryboardFile + Main + + - \ No newline at end of file + UIApplicationSupportsIndirectInputEvents + + UIBackgroundModes + + remote-notification + fetch + + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UIStatusBarHidden + + UIStatusBarStyle + UIStatusBarStyleDarkContent + UISupportedInterfaceOrientations + + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + UIInterfaceOrientationPortrait + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + + UIViewControllerBasedStatusBarAppearance + + + diff --git a/lib/core/config/prefs.dart b/lib/core/config/prefs.dart index faf30a4..7d82b71 100644 --- a/lib/core/config/prefs.dart +++ b/lib/core/config/prefs.dart @@ -1,4 +1,5 @@ import 'package:arttrip/core/app_utils.dart'; +import 'package:arttrip/core/enum.dart'; import 'package:shared_preferences/shared_preferences.dart'; /// SharedPreferences 공용 클래스 @@ -66,4 +67,21 @@ class Prefs { await prefs.setString(_fcmTokenKey, value); } } + + // ============================================ + // Onboarding Step + // ============================================ + + static const String _onboardingStepKey = 'onboarding_step'; + + OnboardingStep? get onboardingStep => + OnboardingStep.fromString(prefs.getString(_onboardingStepKey)); + + Future setOnboardingStep(OnboardingStep? value) async { + if (value == null) { + await prefs.remove(_onboardingStepKey); + } else { + await prefs.setString(_onboardingStepKey, value.type); + } + } } diff --git a/lib/features/auth/services/auth_service.dart b/lib/features/auth/services/auth_service.dart index 50ebcca..e82e396 100644 --- a/lib/features/auth/services/auth_service.dart +++ b/lib/features/auth/services/auth_service.dart @@ -115,11 +115,13 @@ class AuthService { return serverResult.when( success: (tokenResult) async { + final step = OnboardingStep.fromString(tokenResult.onboardingStep); await _tokenStorage.saveTokens( accessToken: tokenResult.accessToken, refreshToken: tokenResult.refreshToken, isFirstLogin: tokenResult.firstLogin, ); + await Prefs().setOnboardingStep(step); if (context.mounted) { unawaited( @@ -131,9 +133,7 @@ class AuthService { return AuthResult.success( firstLogin: tokenResult.firstLogin, - onboardingStep: OnboardingStep.fromString( - tokenResult.onboardingStep, - ), + onboardingStep: step, ); }, failure: (exception) { @@ -217,11 +217,13 @@ class AuthService { return result.when( success: (tokenResult) async { + final step = OnboardingStep.fromString(tokenResult.onboardingStep); await _tokenStorage.saveTokens( accessToken: tokenResult.accessToken, refreshToken: tokenResult.refreshToken, isFirstLogin: tokenResult.firstLogin, ); + await Prefs().setOnboardingStep(step); if (context.mounted) { unawaited( @@ -233,9 +235,7 @@ class AuthService { return AuthResult.success( firstLogin: tokenResult.firstLogin, - onboardingStep: OnboardingStep.fromString( - tokenResult.onboardingStep, - ), + onboardingStep: step, ); }, failure: (exception) { @@ -366,6 +366,7 @@ class AuthService { // 저장된 토큰 삭제 await _tokenStorage.clearTokens(); + await Prefs().setOnboardingStep(OnboardingStep.nickname); debugPrint('로그아웃 완료'); } diff --git a/lib/features/login/login_page.dart b/lib/features/login/login_page.dart index 9386727..38e2c78 100644 --- a/lib/features/login/login_page.dart +++ b/lib/features/login/login_page.dart @@ -6,6 +6,7 @@ import 'package:arttrip/core/enum.dart'; import 'package:arttrip/core/extensions.dart'; import 'package:arttrip/features/auth/services/auth_service.dart'; import 'package:arttrip/routes/app_routes.dart'; +import 'package:arttrip/routes/route_params.dart'; import 'package:arttrip/routes/routes.dart'; import 'package:arttrip/shared/utils/text/arttrip_text.dart'; import 'package:arttrip/shared/widgets/social_login_button.dart'; @@ -88,8 +89,14 @@ class _LoginPageState extends State { try { final params = await AuthService.instance.getGoogleCredentials(); if (!mounted) return; - if (params != null) { - await Routes.push(context, AppRoutes.onboardingTerms, extra: params); + if (params == null) { + _showLoginError(); + return; + } + final result = await AuthService.instance.loginWithServer(context, params); + if (!mounted) return; + if (result.isSuccess) { + _navigateAfterLogin(result.onboardingStep, params); } else { _showLoginError(); } @@ -104,8 +111,14 @@ class _LoginPageState extends State { try { final params = await AuthService.instance.getAppleCredentials(); if (!mounted) return; - if (params != null) { - await Routes.push(context, AppRoutes.onboardingTerms, extra: params); + if (params == null) { + _showLoginError(); + return; + } + final result = await AuthService.instance.loginWithServer(context, params); + if (!mounted) return; + if (result.isSuccess) { + _navigateAfterLogin(result.onboardingStep, params); } else { _showLoginError(); } @@ -120,8 +133,14 @@ class _LoginPageState extends State { try { final params = await AuthService.instance.getKakaoCredentials(); if (!mounted) return; - if (params != null) { - await Routes.push(context, AppRoutes.onboardingTerms, extra: params); + if (params == null) { + _showLoginError(); + return; + } + final result = await AuthService.instance.loginWithServer(context, params); + if (!mounted) return; + if (result.isSuccess) { + _navigateAfterLogin(result.onboardingStep, params); } else { _showLoginError(); } @@ -130,10 +149,18 @@ class _LoginPageState extends State { } } - void _navigateAfterLogin(OnboardingStep? step) { + void _navigateAfterLogin(OnboardingStep? step, [SocialLoginParams? params]) { switch (step) { case OnboardingStep.nickname: - Routes.go(context, AppRoutes.onboardingNickname); + // 신규 유저: 약관 동의 후 닉네임 설정 (서버 로그인은 이미 완료) + Routes.push( + context, + AppRoutes.onboardingTerms, + extra: SocialLoginParams( + provider: params?.provider ?? '', + skipServerLogin: true, + ), + ); case OnboardingStep.keyword: Routes.go(context, AppRoutes.onboardingKeywords); case OnboardingStep.completed: diff --git a/lib/features/onboarding/views/keywords_page.dart b/lib/features/onboarding/views/keywords_page.dart index 1942610..3268284 100644 --- a/lib/features/onboarding/views/keywords_page.dart +++ b/lib/features/onboarding/views/keywords_page.dart @@ -1,3 +1,5 @@ +import 'package:arttrip/core/config/prefs.dart'; +import 'package:arttrip/core/enum.dart'; import 'package:arttrip/core/extensions.dart'; import 'package:arttrip/features/onboarding/data/models/keyword_model.dart'; import 'package:arttrip/features/onboarding/viewmodels/keywords_viewmodel.dart'; @@ -236,7 +238,8 @@ class _KeywordModelsPageState extends State { if (widget.isEditMode) { AppToast.show(context, message: context.l10n.keywordSaveSuccess); } else { - Routes.go(context, '/'); + await Prefs().setOnboardingStep(OnboardingStep.completed); + if (mounted) Routes.go(context, '/'); } } } else { diff --git a/lib/features/onboarding/views/nickname_input_page.dart b/lib/features/onboarding/views/nickname_input_page.dart index 019e8fa..2b4f0e8 100644 --- a/lib/features/onboarding/views/nickname_input_page.dart +++ b/lib/features/onboarding/views/nickname_input_page.dart @@ -1,4 +1,6 @@ import 'package:arttrip/core/app_colors.dart'; +import 'package:arttrip/core/config/prefs.dart'; +import 'package:arttrip/core/enum.dart'; import 'package:arttrip/core/extensions.dart'; import 'package:arttrip/features/my/viewmodels/my_viewmodel.dart'; import 'package:arttrip/routes/app_routes.dart'; @@ -195,6 +197,8 @@ class _NicknameInputPageState extends State { if (!mounted) return; if (errorMessage == null) { + await Prefs().setOnboardingStep(OnboardingStep.keyword); + if (!mounted) return; await Routes.replace(context, AppRoutes.onboardingKeywords); return; } diff --git a/lib/features/onboarding/views/terms_agreement_page.dart b/lib/features/onboarding/views/terms_agreement_page.dart index 5bd6436..ea46d50 100644 --- a/lib/features/onboarding/views/terms_agreement_page.dart +++ b/lib/features/onboarding/views/terms_agreement_page.dart @@ -1,5 +1,6 @@ import 'package:arttrip/core/app_assets.dart'; import 'package:arttrip/core/app_colors.dart'; +import 'package:arttrip/core/config/prefs.dart'; import 'package:arttrip/core/enum.dart'; import 'package:arttrip/core/extensions.dart'; import 'package:arttrip/features/auth/services/auth_service.dart'; @@ -43,6 +44,13 @@ class _TermsAgreementPageState extends State { Future _onNext() async { if (!_allChecked || _isLoading) return; + if (widget.params.skipServerLogin) { + await Prefs().setOnboardingStep(OnboardingStep.nickname); + if (!mounted) return; + Routes.go(context, AppRoutes.onboardingNickname); + return; + } + setState(() => _isLoading = true); try { final result = await AuthService.instance.loginWithServer( diff --git a/lib/features/splash/views/splash_view.dart b/lib/features/splash/views/splash_view.dart index 4d6e4e7..867c058 100644 --- a/lib/features/splash/views/splash_view.dart +++ b/lib/features/splash/views/splash_view.dart @@ -2,12 +2,14 @@ import 'dart:io'; import 'package:arttrip/core/app_colors.dart'; import 'package:arttrip/core/app_utils.dart'; +import 'package:arttrip/core/config/prefs.dart'; import 'package:arttrip/core/enum.dart'; import 'package:arttrip/core/extensions.dart'; import 'package:arttrip/features/auth/services/auth_service.dart'; import 'package:arttrip/features/splash/data/maintenance_model.dart'; import 'package:arttrip/features/splash/viewmodels/splash_viewmodel.dart'; import 'package:arttrip/routes/app_router.dart'; +import 'package:arttrip/routes/app_routes.dart'; import 'package:arttrip/routes/routes.dart'; import 'package:arttrip/shared/utils/text/arttrip_text.dart'; import 'package:arttrip/shared/widgets/init_widget.dart'; @@ -161,7 +163,15 @@ class _SplashViewState extends State { void _navigateToHome() { if (!mounted) return; - Routes.go(context, '/'); + switch (Prefs().onboardingStep) { + case OnboardingStep.nickname: + Routes.go(context, AppRoutes.onboardingNickname); + case OnboardingStep.keyword: + Routes.go(context, AppRoutes.onboardingKeywords); + case OnboardingStep.completed: + case null: + Routes.go(context, '/'); + } } @override diff --git a/lib/routes/route_params.dart b/lib/routes/route_params.dart index c484bbf..08f9f7c 100644 --- a/lib/routes/route_params.dart +++ b/lib/routes/route_params.dart @@ -36,9 +36,13 @@ class SocialLoginParams { required this.provider, this.idToken, this.authorizationCode, + this.skipServerLogin = false, }); final String provider; final String? idToken; final String? authorizationCode; + + /// true이면 서버 로그인이 이미 완료된 상태 — 약관동의 UI만 표시 후 닉네임 화면으로 이동 + final bool skipServerLogin; } From 9a19a952b6e82712296792f1bbc5f116930cbadf Mon Sep 17 00:00:00 2001 From: Ju Date: Fri, 15 May 2026 12:50:45 +0900 Subject: [PATCH 02/17] =?UTF-8?q?fix:=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20?= =?UTF-8?q?=EB=B9=88=20=EA=B2=BD=EC=9A=B0=20=EC=86=8C,=EC=A4=91,=EB=8C=80?= =?UTF-8?q?=20=EC=9C=84=EC=A0=AF=20=EC=B6=94=EA=B0=80.=20=ED=95=98?= =?UTF-8?q?=EB=8B=A8=20GNB=20safearea=20=EC=B6=94=EA=B0=80.=20=EC=A7=80?= =?UTF-8?q?=EB=8F=84=20=ED=95=98=EB=8B=A8=20=EC=8B=9C=ED=8A=B8=20=EC=86=90?= =?UTF-8?q?=EC=9E=A1=EC=9D=B4=20drag=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../exhibit/widgets/exhibit_poster_image.dart | 25 +++---- .../write_review/write_review_header.dart | 30 ++++----- .../views/personalized_exhibits_view.dart | 11 +--- .../home/views/regional_exhibits_view.dart | 6 ++ .../home/widgets/today_exhibit_widget.dart | 13 +--- .../map/widgets/map_bottom_sheet.dart | 65 +++++++++++++++---- lib/features/my/widgets/my_review_item.dart | 3 + lib/l10n/app_ko.arb | 3 +- lib/shared/widgets/app_cached_image.dart | 34 ++-------- lib/shared/widgets/exhibit_list_item.dart | 17 ++--- .../widgets/image_empty_big_widget.dart | 39 +++++++++++ .../widgets/image_empty_medium_widget.dart | 39 +++++++++++ .../widgets/image_empty_small_widget.dart | 28 ++++++++ lib/shared/widgets/main_shell.dart | 2 +- 14 files changed, 210 insertions(+), 105 deletions(-) create mode 100644 lib/shared/widgets/image_empty_big_widget.dart create mode 100644 lib/shared/widgets/image_empty_medium_widget.dart create mode 100644 lib/shared/widgets/image_empty_small_widget.dart diff --git a/lib/features/exhibit/widgets/exhibit_poster_image.dart b/lib/features/exhibit/widgets/exhibit_poster_image.dart index 1e0664b..1d4573b 100644 --- a/lib/features/exhibit/widgets/exhibit_poster_image.dart +++ b/lib/features/exhibit/widgets/exhibit_poster_image.dart @@ -1,6 +1,6 @@ -import 'package:arttrip/core/app_colors.dart'; import 'package:arttrip/shared/widgets/app_cached_image.dart'; import 'package:arttrip/shared/widgets/exhibit_status_badge.dart'; +import 'package:arttrip/shared/widgets/image_empty_big_widget.dart'; import 'package:flutter/material.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; @@ -17,22 +17,13 @@ class ExhibitPosterImage extends StatelessWidget { children: [ posterUrl != null ? AppCachedImage( - imageUrl: posterUrl!, - width: double.infinity, - height: 276.h, - fit: BoxFit.cover, - ) - : Container( - width: double.infinity, - height: 276.h, - color: AppColors.gray100, - child: const Center( - child: Icon( - Icons.image_not_supported, - color: AppColors.textTertiary, - ), - ), - ), + imageUrl: posterUrl!, + width: double.infinity, + height: 276.h, + fit: BoxFit.cover, + ) + : ImageEmptyBigWidget(width: double.infinity, height: 276.h), + if (status != null && status!.isNotEmpty) Positioned( top: 16.h, diff --git a/lib/features/exhibit/widgets/write_review/write_review_header.dart b/lib/features/exhibit/widgets/write_review/write_review_header.dart index cd0282d..ed79755 100644 --- a/lib/features/exhibit/widgets/write_review/write_review_header.dart +++ b/lib/features/exhibit/widgets/write_review/write_review_header.dart @@ -2,6 +2,7 @@ import 'package:arttrip/core/app_colors.dart'; import 'package:arttrip/features/exhibit/data/models/write_review_params.dart'; import 'package:arttrip/shared/utils/text/arttrip_text.dart'; import 'package:arttrip/shared/widgets/app_cached_image.dart'; +import 'package:arttrip/shared/widgets/image_empty_small_widget.dart'; import 'package:flutter/material.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; @@ -19,24 +20,21 @@ class WriteReviewHeader extends StatelessWidget { children: [ params.posterUrl != null ? AppCachedImage( - imageUrl: params.posterUrl!, - width: 50.w, - height: 50.w, - borderRadius: BorderRadius.circular(4.r), - ) - : Container( - width: 50.w, - height: 50.w, - decoration: BoxDecoration( - color: AppColors.gray100, + imageUrl: params.posterUrl!, + width: 50.w, + height: 50.w, borderRadius: BorderRadius.circular(4.r), + errorWidget: (_, _, _) => const ImageEmptySmallWidget(), + ) + : Container( + width: 50.w, + height: 50.w, + decoration: BoxDecoration( + color: AppColors.gray100, + borderRadius: BorderRadius.circular(4.r), + ), + child: const ImageEmptySmallWidget(), ), - child: Icon( - Icons.image_not_supported, - size: 20.w, - color: AppColors.textTertiary, - ), - ), SizedBox(width: 16.w), Expanded( child: Column( diff --git a/lib/features/home/views/personalized_exhibits_view.dart b/lib/features/home/views/personalized_exhibits_view.dart index 4de85c0..1576b7d 100644 --- a/lib/features/home/views/personalized_exhibits_view.dart +++ b/lib/features/home/views/personalized_exhibits_view.dart @@ -12,6 +12,7 @@ import 'package:arttrip/routes/routes.dart'; import 'package:arttrip/shared/utils/text/arttrip_text.dart'; import 'package:arttrip/shared/widgets/app_cached_image.dart'; import 'package:arttrip/shared/widgets/async_view.dart'; +import 'package:arttrip/shared/widgets/image_empty_big_widget.dart'; import 'package:arttrip/shared/widgets/shimmer_skeleton_item.dart'; import 'package:flutter/material.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; @@ -106,17 +107,11 @@ class _PersonalizedExhibitsViewState extends State { height: 150.h, fit: BoxFit.cover, ) - : Container( + : ImageEmptyBigWidget( width: 120.w, height: 150.h, - color: AppColors.gray100, - child: const Center( - child: Icon( - Icons.image_not_supported, - color: AppColors.textTertiary, - ), - ), ), + location?.isNotEmpty == true ? Container( padding: EdgeInsets.symmetric( diff --git a/lib/features/home/views/regional_exhibits_view.dart b/lib/features/home/views/regional_exhibits_view.dart index eda269e..6657b55 100644 --- a/lib/features/home/views/regional_exhibits_view.dart +++ b/lib/features/home/views/regional_exhibits_view.dart @@ -7,6 +7,7 @@ import 'package:arttrip/shared/models/region_model.dart'; import 'package:arttrip/shared/utils/text/arttrip_text.dart'; import 'package:arttrip/shared/widgets/app_cached_image.dart'; import 'package:arttrip/shared/widgets/async_view.dart'; +import 'package:arttrip/shared/widgets/image_empty_small_widget.dart'; import 'package:arttrip/shared/widgets/shimmer_skeleton_item.dart'; import 'package:flutter/material.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; @@ -79,6 +80,11 @@ class _RegionalExhibitsViewState extends State { imageUrl: item.imageUrl, width: 64.w, height: 64.w, + errorWidget: (p0, p1, p2) => + ImageEmptySmallWidget( + width: 64.w, + height: 64.w, + ), ) : const SizedBox.shrink(), ), diff --git a/lib/features/home/widgets/today_exhibit_widget.dart b/lib/features/home/widgets/today_exhibit_widget.dart index 10d9b24..7fe7e07 100644 --- a/lib/features/home/widgets/today_exhibit_widget.dart +++ b/lib/features/home/widgets/today_exhibit_widget.dart @@ -3,6 +3,7 @@ import 'package:arttrip/core/app_colors.dart'; import 'package:arttrip/features/exhibit/data/models/exhibit_model.dart'; import 'package:arttrip/shared/utils/text/arttrip_text.dart'; import 'package:arttrip/shared/widgets/app_cached_image.dart'; +import 'package:arttrip/shared/widgets/image_empty_big_widget.dart'; import 'package:flutter/material.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:flutter_svg/flutter_svg.dart'; @@ -39,17 +40,7 @@ class TodayExhibitWidget extends StatelessWidget { height: 240.h, fit: BoxFit.cover, ) - : Container( - width: 180.w, - height: 240.h, - color: AppColors.gray100, - child: const Center( - child: Icon( - Icons.image_not_supported, - color: AppColors.textTertiary, - ), - ), - ), + : ImageEmptyBigWidget(width: 180.w, height: 240.h), /// 테두리 Container( diff --git a/lib/features/map/widgets/map_bottom_sheet.dart b/lib/features/map/widgets/map_bottom_sheet.dart index c2331b5..8834356 100644 --- a/lib/features/map/widgets/map_bottom_sheet.dart +++ b/lib/features/map/widgets/map_bottom_sheet.dart @@ -64,7 +64,9 @@ class MapBottomSheet extends StatelessWidget { // 드래그 핸들 (스크롤 시 고정) SliverPersistentHeader( pinned: true, - delegate: _HandleHeaderDelegate(onTap: _onHandleTap), + delegate: _HandleHeaderDelegate( + sheetController: sheetController, + ), ), // 접힌 상태: "전시 리스트 확인하기" @@ -155,22 +157,14 @@ class MapBottomSheet extends StatelessWidget { ); } - void _onHandleTap() { - if (!sheetController.isAttached) return; - final target = sheetController.size < 0.5 ? 0.8 : 0.4; - sheetController.animateTo( - target, - duration: const Duration(milliseconds: 300), - curve: Curves.easeOut, - ); - } } class _HandleHeaderDelegate extends SliverPersistentHeaderDelegate { - const _HandleHeaderDelegate({required this.onTap}); + _HandleHeaderDelegate({required this.sheetController}); - final VoidCallback onTap; + final DraggableScrollableController sheetController; static const double _height = 28; + static const _snapSizes = [0.07, 0.4, 0.8]; @override double get maxExtent => _height; @@ -181,6 +175,49 @@ class _HandleHeaderDelegate extends SliverPersistentHeaderDelegate { @override bool shouldRebuild(covariant _HandleHeaderDelegate oldDelegate) => false; + void _onTap() { + if (!sheetController.isAttached) return; + final target = sheetController.size < 0.5 ? 0.8 : 0.4; + sheetController.animateTo( + target, + duration: const Duration(milliseconds: 300), + curve: Curves.easeOut, + ); + } + + void _onDragUpdate(DragUpdateDetails details, BuildContext context) { + if (!sheetController.isAttached) return; + final screenHeight = MediaQuery.of(context).size.height; + final delta = -details.delta.dy / screenHeight; + final newSize = (sheetController.size + delta).clamp(0.07, 0.8); + sheetController.jumpTo(newSize); + } + + void _onDragEnd(DragEndDetails details) { + if (!sheetController.isAttached) return; + final velocity = details.primaryVelocity ?? 0; + final current = sheetController.size; + + double target; + if (velocity < -300) { + final above = _snapSizes.where((s) => s > current + 0.01).toList(); + target = above.isNotEmpty ? above.first : _snapSizes.last; + } else if (velocity > 300) { + final below = _snapSizes.where((s) => s < current - 0.01).toList(); + target = below.isNotEmpty ? below.last : _snapSizes.first; + } else { + target = _snapSizes.reduce( + (a, b) => (a - current).abs() < (b - current).abs() ? a : b, + ); + } + + sheetController.animateTo( + target, + duration: const Duration(milliseconds: 300), + curve: Curves.easeOut, + ); + } + @override Widget build( BuildContext context, @@ -188,7 +225,9 @@ class _HandleHeaderDelegate extends SliverPersistentHeaderDelegate { bool overlapsContent, ) { return GestureDetector( - onTap: onTap, + onTap: _onTap, + onVerticalDragUpdate: (d) => _onDragUpdate(d, context), + onVerticalDragEnd: _onDragEnd, behavior: HitTestBehavior.opaque, child: Container( height: _height, diff --git a/lib/features/my/widgets/my_review_item.dart b/lib/features/my/widgets/my_review_item.dart index f1a20d1..c0fe1c7 100644 --- a/lib/features/my/widgets/my_review_item.dart +++ b/lib/features/my/widgets/my_review_item.dart @@ -3,6 +3,7 @@ import 'package:arttrip/core/extensions.dart'; import 'package:arttrip/features/my/data/models/my_review_model.dart'; import 'package:arttrip/shared/utils/text/arttrip_text.dart'; import 'package:arttrip/shared/widgets/app_cached_image.dart'; +import 'package:arttrip/shared/widgets/image_empty_small_widget.dart'; import 'package:flutter/material.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; @@ -108,6 +109,8 @@ class MyReviewItem extends StatelessWidget { width: 72.w, height: 72.h, borderRadius: BorderRadius.circular(4.r), + errorWidget: (p0, p1, p2) => + ImageEmptySmallWidget(width: 72.w, height: 72.h), ); } diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index 21a7714..e715283 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -185,7 +185,8 @@ "minutesAgo": "{min}분 전", "hoursAgo": "{hour}시간 전", "noAlertsTitle": "알림이 없습니다.", - "noImage": "이미지 준비중입니다", + "noImageBig": "이미지 준비중입니다", + "noImageMedium": "이미지 준비중", "maintenanceTitle": "📢 서버 점검 안내", "maintenanceMessage": "보다 안정적인 서비스 제공을 위해\n현재 서버 점검이 진행 중입니다.", "termsAgreementDescription": "아트트립에 오신 것을 환영합니다\n이용약관에 동의해주세요", diff --git a/lib/shared/widgets/app_cached_image.dart b/lib/shared/widgets/app_cached_image.dart index beba85b..e201c7b 100644 --- a/lib/shared/widgets/app_cached_image.dart +++ b/lib/shared/widgets/app_cached_image.dart @@ -1,13 +1,8 @@ -import 'package:arttrip/core/app_assets.dart'; -import 'package:arttrip/core/app_colors.dart'; import 'package:arttrip/core/app_consts.dart'; -import 'package:arttrip/core/extensions.dart'; -import 'package:arttrip/shared/utils/text/arttrip_text.dart'; +import 'package:arttrip/shared/widgets/image_empty_big_widget.dart'; import 'package:arttrip/shared/widgets/shimmer_skeleton_item.dart'; import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_screenutil/flutter_screenutil.dart'; -import 'package:flutter_svg/svg.dart'; import 'package:shimmer_animation/shimmer_animation.dart'; /// 공용 네트워크 이미지 위젯 @@ -22,6 +17,7 @@ class AppCachedImage extends StatelessWidget { this.height, this.fit = BoxFit.cover, this.borderRadius, + this.errorWidget, }); final String imageUrl; @@ -29,6 +25,7 @@ class AppCachedImage extends StatelessWidget { final double? height; final BoxFit fit; final BorderRadius? borderRadius; + final Widget Function(BuildContext, String, Object)? errorWidget; @override Widget build(BuildContext context) { @@ -41,27 +38,10 @@ class AppCachedImage extends StatelessWidget { height: height, fit: fit, placeholder: (context, url) => _buildPlaceholder(), - errorWidget: (context, url, error) => Container( - width: width, - height: height, - color: AppColors.gray50, - child: Center( - child: Column( - spacing: 8.h, - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - SvgPicture.asset(AppAssets.icNoImage, width: 63.w, height: 20.h), - ArtTripText.pretendard() - .body02Bold() - .color(AppColors.textTertiary) - .textAlign(TextAlign.center) - .build() - .text(context.l10n.noImage), - ], - ), - ), - ), + errorWidget: + errorWidget ?? + (context, url, error) => + ImageEmptyBigWidget(width: width, height: height), ); if (borderRadius != null) { diff --git a/lib/shared/widgets/exhibit_list_item.dart b/lib/shared/widgets/exhibit_list_item.dart index 1406ff1..00b3eeb 100644 --- a/lib/shared/widgets/exhibit_list_item.dart +++ b/lib/shared/widgets/exhibit_list_item.dart @@ -7,6 +7,7 @@ import 'package:arttrip/routes/routes.dart'; import 'package:arttrip/shared/utils/text/arttrip_text.dart'; import 'package:arttrip/shared/widgets/app_cached_image.dart'; import 'package:arttrip/shared/widgets/exhibit_status_badge.dart'; +import 'package:arttrip/shared/widgets/image_empty_medium_widget.dart'; import 'package:flutter/material.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:flutter_svg/svg.dart'; @@ -38,18 +39,12 @@ class ExhibitListItem extends StatelessWidget { width: 100.w, height: 100.w, fit: BoxFit.cover, - ) - : Container( - width: 100.w, - height: 100.w, - color: AppColors.gray100, - child: const Center( - child: Icon( - Icons.image_not_supported, - color: AppColors.textTertiary, - ), + errorWidget: (p0, p1, p2) => ImageEmptyMediumWidget( + width: 100.w, + height: 100.w, ), - ), + ) + : ImageEmptyMediumWidget(width: 100.w, height: 100.w), /// 전시 상태 item.status != null diff --git a/lib/shared/widgets/image_empty_big_widget.dart b/lib/shared/widgets/image_empty_big_widget.dart new file mode 100644 index 0000000..768200f --- /dev/null +++ b/lib/shared/widgets/image_empty_big_widget.dart @@ -0,0 +1,39 @@ +import 'package:arttrip/core/app_assets.dart'; +import 'package:arttrip/core/app_colors.dart'; +import 'package:arttrip/core/extensions.dart'; +import 'package:arttrip/shared/utils/text/arttrip_text.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:flutter_svg/svg.dart'; + +class ImageEmptyBigWidget extends StatelessWidget { + const ImageEmptyBigWidget({super.key, this.width, this.height}); + + final double? width; + final double? height; + + @override + Widget build(BuildContext context) { + return Container( + width: width, + height: height, + color: AppColors.gray50, + child: Center( + child: Column( + spacing: 8.h, + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + SvgPicture.asset(AppAssets.icNoImage, width: 63.w, height: 20.h), + ArtTripText.pretendard() + .body02Bold() + .color(AppColors.textTertiary) + .textAlign(TextAlign.center) + .build() + .text(context.l10n.noImageBig), + ], + ), + ), + ); + } +} diff --git a/lib/shared/widgets/image_empty_medium_widget.dart b/lib/shared/widgets/image_empty_medium_widget.dart new file mode 100644 index 0000000..93d629c --- /dev/null +++ b/lib/shared/widgets/image_empty_medium_widget.dart @@ -0,0 +1,39 @@ +import 'package:arttrip/core/app_assets.dart'; +import 'package:arttrip/core/app_colors.dart'; +import 'package:arttrip/core/extensions.dart'; +import 'package:arttrip/shared/utils/text/arttrip_text.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:flutter_svg/svg.dart'; + +class ImageEmptyMediumWidget extends StatelessWidget { + const ImageEmptyMediumWidget({super.key, this.width, this.height}); + + final double? width; + final double? height; + + @override + Widget build(BuildContext context) { + return Container( + width: width, + height: height, + color: AppColors.gray50, + child: Center( + child: Column( + spacing: 8.h, + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + SvgPicture.asset(AppAssets.icNoImage, width: 63.w, height: 20.h), + ArtTripText.pretendard() + .body02Bold() + .color(AppColors.textTertiary) + .textAlign(TextAlign.center) + .build() + .text(context.l10n.noImageMedium), + ], + ), + ), + ); + } +} diff --git a/lib/shared/widgets/image_empty_small_widget.dart b/lib/shared/widgets/image_empty_small_widget.dart new file mode 100644 index 0000000..638a951 --- /dev/null +++ b/lib/shared/widgets/image_empty_small_widget.dart @@ -0,0 +1,28 @@ +import 'package:arttrip/core/app_assets.dart'; +import 'package:arttrip/core/app_colors.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:flutter_svg/svg.dart'; + +class ImageEmptySmallWidget extends StatelessWidget { + const ImageEmptySmallWidget({super.key, this.width, this.height}); + + final double? width; + final double? height; + + @override + Widget build(BuildContext context) { + return Container( + width: width, + height: height, + color: AppColors.gray50, + child: Center( + child: SvgPicture.asset( + AppAssets.icNoImage, + width: 37.8.w, + height: 12.h, + ), + ), + ); + } +} diff --git a/lib/shared/widgets/main_shell.dart b/lib/shared/widgets/main_shell.dart index 5f7b90c..c397a09 100644 --- a/lib/shared/widgets/main_shell.dart +++ b/lib/shared/widgets/main_shell.dart @@ -23,7 +23,7 @@ class _MainShellState extends State { Widget build(BuildContext context) { return Scaffold( extendBody: true, - body: widget.navigationShell, + body: SafeArea(child: widget.navigationShell), bottomNavigationBar: BottomNavBar( key: _bottomNavigationKey, currentIndex: widget.navigationShell.currentIndex, From 5d72392ab34ee5393726622249a836a4f7f33acf Mon Sep 17 00:00:00 2001 From: Ju Date: Fri, 15 May 2026 13:19:03 +0900 Subject: [PATCH 03/17] =?UTF-8?q?fix:=20=ED=95=98=EB=8B=A8=20GNB=20?= =?UTF-8?q?=EB=A1=9C=20=EC=9D=B8=ED=95=9C=20=EA=B0=81=20=EB=A9=94=EC=9D=B8?= =?UTF-8?q?=20ui=20=EA=B9=A8=EC=A7=90=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/features/favorite/favorites_page.dart | 286 +++++++++++----------- lib/features/map/views/map_view.dart | 29 ++- lib/features/stamp/stamp_page.dart | 1 + lib/shared/widgets/main_shell.dart | 3 +- 4 files changed, 164 insertions(+), 155 deletions(-) diff --git a/lib/features/favorite/favorites_page.dart b/lib/features/favorite/favorites_page.dart index 9e5226d..abf366e 100644 --- a/lib/features/favorite/favorites_page.dart +++ b/lib/features/favorite/favorites_page.dart @@ -121,159 +121,161 @@ class _FavoritesPageState extends State { title: context.l10n.navStorage, actions: const [AlertBadge()], ), - body: RefreshIndicator( - onRefresh: _getFavoriteExhibits, - child: Column( - children: [ - Padding( - padding: EdgeInsets.symmetric(horizontal: 24.w), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - /// 조회 결과 개수 - Padding( - padding: EdgeInsets.only(top: 11.h, bottom: 19.h), - child: ValueListenableBuilder( - valueListenable: _exhibits, - builder: (context, exhibits, child) { - return ArtTripText.pretendard() - .title02Bold() - .build() - .text( - context.l10n.totalCount(exhibits?.length ?? 0), - ); - }, - ), - ), - - /// 최신순, 마감순, 필터 - ValueListenableBuilder( - valueListenable: _sortType, - builder: (context, sortType, child) { - return Row( - children: [ - _buildSortType( - isSelected: sortType == SortType.latest, - sortType: SortType.latest, - sortTypeName: context.l10n.sortByLatest, - ), - Container( - width: 1.w, - height: 12.h, - margin: EdgeInsets.symmetric(horizontal: 8.w), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(100), - color: AppColors.gray100, - ), - ), - _buildSortType( - isSelected: sortType == SortType.endingSoon, - sortType: SortType.endingSoon, - sortTypeName: context.l10n.sortByEndingSoon, - ), - SizedBox(width: 12.w), - GestureDetector( - onTap: () => _showFilterBottomSheet(), - child: SvgPicture.asset( - AppAssets.icFilter, - width: 24.w, - height: 24.w, - ), - ), - ], - ); - }, - ), - ], - ), - ), - - /// 즐겨찾기 전시 리스트 - ValueListenableBuilder( - valueListenable: _isLoading, - builder: (context, isLoading, child) { - if (isLoading) { - return Expanded( - child: Padding( - padding: EdgeInsets.symmetric( - vertical: 12.h, - horizontal: 24.w, + body: SafeArea( + child: RefreshIndicator( + onRefresh: _getFavoriteExhibits, + child: Column( + children: [ + Padding( + padding: EdgeInsets.symmetric(horizontal: 24.w), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + /// 조회 결과 개수 + Padding( + padding: EdgeInsets.only(top: 11.h, bottom: 19.h), + child: ValueListenableBuilder( + valueListenable: _exhibits, + builder: (context, exhibits, child) { + return ArtTripText.pretendard() + .title02Bold() + .build() + .text( + context.l10n.totalCount(exhibits?.length ?? 0), + ); + }, ), - child: const ExhibitsLoadingView(), ), - ); - } - return ValueListenableBuilder( - valueListenable: _exhibits, - builder: (context, exhibits, child) { - if (exhibits == null) { - return const ExceptionView(); - } else if (exhibits.isEmpty) { - return Expanded( - child: Column( - spacing: 8.h, - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.center, + /// 최신순, 마감순, 필터 + ValueListenableBuilder( + valueListenable: _sortType, + builder: (context, sortType, child) { + return Row( children: [ - SvgPicture.asset( - AppAssets.icFavorite, - width: 96.w, - height: 96.w, + _buildSortType( + isSelected: sortType == SortType.latest, + sortType: SortType.latest, + sortTypeName: context.l10n.sortByLatest, + ), + Container( + width: 1.w, + height: 12.h, + margin: EdgeInsets.symmetric(horizontal: 8.w), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(100), + color: AppColors.gray100, + ), + ), + _buildSortType( + isSelected: sortType == SortType.endingSoon, + sortType: SortType.endingSoon, + sortTypeName: context.l10n.sortByEndingSoon, + ), + SizedBox(width: 12.w), + GestureDetector( + onTap: () => _showFilterBottomSheet(), + child: SvgPicture.asset( + AppAssets.icFilter, + width: 24.w, + height: 24.w, + ), ), - ArtTripText.pretendard() - .body01Regular() - .color(AppColors.textTertiary) - .build() - .text(context.l10n.noFavorites), ], - ), - ); - } + ); + }, + ), + ], + ), + ), - return ValueListenableBuilder( - valueListenable: _loadingMore, - builder: (context, loadingMore, child) { - final int length = - exhibits.length + (loadingMore ? 1 : 0); + /// 즐겨찾기 전시 리스트 + ValueListenableBuilder( + valueListenable: _isLoading, + builder: (context, isLoading, child) { + if (isLoading) { + return Expanded( + child: Padding( + padding: EdgeInsets.symmetric( + vertical: 12.h, + horizontal: 24.w, + ), + child: const ExhibitsLoadingView(), + ), + ); + } + return ValueListenableBuilder( + valueListenable: _exhibits, + builder: (context, exhibits, child) { + if (exhibits == null) { + return const ExceptionView(); + } else if (exhibits.isEmpty) { return Expanded( - child: ListView.separated( - physics: const AlwaysScrollableScrollPhysics(), - controller: _scrollController, - padding: EdgeInsets.only( - left: 24.w, - right: 24.w, - top: 8.h, - bottom: 16.h, - ), - itemCount: length, - separatorBuilder: (context, index) => - SizedBox(height: 12.h), - itemBuilder: (context, index) { - if (loadingMore && index == exhibits.length) { - return const Center( - child: CircularProgressIndicator(), - ); - } - - final ExhibitModel item = exhibits[index]; - return ExhibitListItem( - item: item, - showArea: - _selectedArea.value == - context.l10n.allItems, - ); - }, + child: Column( + spacing: 8.h, + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + SvgPicture.asset( + AppAssets.icFavorite, + width: 96.w, + height: 96.w, + ), + ArtTripText.pretendard() + .body01Regular() + .color(AppColors.textTertiary) + .build() + .text(context.l10n.noFavorites), + ], ), ); - }, - ); - }, - ); - }, - ), - ], + } + + return ValueListenableBuilder( + valueListenable: _loadingMore, + builder: (context, loadingMore, child) { + final int length = + exhibits.length + (loadingMore ? 1 : 0); + + return Expanded( + child: ListView.separated( + physics: const AlwaysScrollableScrollPhysics(), + controller: _scrollController, + padding: EdgeInsets.only( + left: 24.w, + right: 24.w, + top: 8.h, + bottom: 16.h, + ), + itemCount: length, + separatorBuilder: (context, index) => + SizedBox(height: 12.h), + itemBuilder: (context, index) { + if (loadingMore && index == exhibits.length) { + return const Center( + child: CircularProgressIndicator(), + ); + } + + final ExhibitModel item = exhibits[index]; + return ExhibitListItem( + item: item, + showArea: + _selectedArea.value == + context.l10n.allItems, + ); + }, + ), + ); + }, + ); + }, + ); + }, + ), + ], + ), ), ), ); diff --git a/lib/features/map/views/map_view.dart b/lib/features/map/views/map_view.dart index ffa12ab..efe39c4 100644 --- a/lib/features/map/views/map_view.dart +++ b/lib/features/map/views/map_view.dart @@ -55,18 +55,23 @@ class _MapViewState extends State { return InitWidget( init: () => context.read().init(), child: Scaffold( - body: Selector>>( - selector: (_, vm) => vm.markersState, - builder: (context, markersState, _) { - return AsyncView>( - state: markersState, - onLoading: () => _buildMapStack(context), - onData: (markers) { - _updateClusterItems(markers); - return _buildMapStack(context); - }, - ); - }, + body: Padding( + padding: EdgeInsets.only( + bottom: MediaQuery.of(context).padding.bottom, + ), + child: Selector>>( + selector: (_, vm) => vm.markersState, + builder: (context, markersState, _) { + return AsyncView>( + state: markersState, + onLoading: () => _buildMapStack(context), + onData: (markers) { + _updateClusterItems(markers); + return _buildMapStack(context); + }, + ); + }, + ), ), ), ); diff --git a/lib/features/stamp/stamp_page.dart b/lib/features/stamp/stamp_page.dart index fd56ea5..7fdb368 100644 --- a/lib/features/stamp/stamp_page.dart +++ b/lib/features/stamp/stamp_page.dart @@ -20,6 +20,7 @@ class _StampPageState extends State { final topPadding = MediaQuery.of(context).padding.top; return Scaffold( + extendBodyBehindAppBar: true, backgroundColor: AppColors.gray0, body: Stack( children: [ diff --git a/lib/shared/widgets/main_shell.dart b/lib/shared/widgets/main_shell.dart index c397a09..893c65c 100644 --- a/lib/shared/widgets/main_shell.dart +++ b/lib/shared/widgets/main_shell.dart @@ -23,7 +23,8 @@ class _MainShellState extends State { Widget build(BuildContext context) { return Scaffold( extendBody: true, - body: SafeArea(child: widget.navigationShell), + extendBodyBehindAppBar: true, + body: widget.navigationShell, bottomNavigationBar: BottomNavBar( key: _bottomNavigationKey, currentIndex: widget.navigationShell.currentIndex, From 602692bec989e3fcec8e44db91014becde29295d Mon Sep 17 00:00:00 2001 From: Ju Date: Fri, 15 May 2026 16:41:21 +0900 Subject: [PATCH 04/17] =?UTF-8?q?feat:=20=EC=95=8C=EB=A6=BC=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=20API=20=EC=97=B0=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/core/api_endpoints.dart | 1 + lib/core/config/prefs.dart | 12 +++++ lib/features/my/data/my_repository.dart | 17 +++++++ lib/features/my/data/my_repository_mock.dart | 6 +++ lib/features/my/viewmodels/my_viewmodel.dart | 4 ++ lib/features/my/views/my_page.dart | 18 ++++---- .../my/views/notification_settings_page.dart | 46 ++++++++++++------- lib/l10n/app_ko.arb | 1 - 8 files changed, 79 insertions(+), 26 deletions(-) diff --git a/lib/core/api_endpoints.dart b/lib/core/api_endpoints.dart index 9d4421e..4926563 100644 --- a/lib/core/api_endpoints.dart +++ b/lib/core/api_endpoints.dart @@ -44,6 +44,7 @@ class ApiEndpoints { static const me = '/me'; static const meImage = '/me/image'; static const meRecentExhibits = '/me/recent-exhibits'; + static const mePushEnabled = '/me/push-enabled'; // ─── Keywords ─── static const keywordAll = '/keyword/all'; diff --git a/lib/core/config/prefs.dart b/lib/core/config/prefs.dart index 7d82b71..aa52350 100644 --- a/lib/core/config/prefs.dart +++ b/lib/core/config/prefs.dart @@ -68,6 +68,18 @@ class Prefs { } } + // ============================================ + // Notification + // ============================================ + + static const String _pushEnabledKey = 'push_enabled'; + + bool get pushEnabled => prefs.getBool(_pushEnabledKey) ?? true; + + Future setPushEnabled(bool value) async { + await prefs.setBool(_pushEnabledKey, value); + } + // ============================================ // Onboarding Step // ============================================ diff --git a/lib/features/my/data/my_repository.dart b/lib/features/my/data/my_repository.dart index 7f494bc..a2a6183 100644 --- a/lib/features/my/data/my_repository.dart +++ b/lib/features/my/data/my_repository.dart @@ -35,6 +35,9 @@ abstract class MyRepository { /// fcm token 등록 Future registerFcmToken(String token); + + /// 푸시 알림 수신 여부 변경 + Future updatePushEnabled(bool enabled); } class MyRepositoryImpl implements MyRepository { @@ -175,4 +178,18 @@ class MyRepositoryImpl implements MyRepository { AppUtil.debugLog('registerFcmToken failed: $e'); } } + + @override + Future updatePushEnabled(bool enabled) async { + try { + final response = await _dio.patch( + ApiEndpoints.mePushEnabled, + data: {'enabled': enabled}, + ); + return response.isSuccess; + } catch (e) { + AppUtil.debugLog('updatePushEnabled: $e'); + } + return false; + } } diff --git a/lib/features/my/data/my_repository_mock.dart b/lib/features/my/data/my_repository_mock.dart index 8eeba4e..9117602 100644 --- a/lib/features/my/data/my_repository_mock.dart +++ b/lib/features/my/data/my_repository_mock.dart @@ -109,4 +109,10 @@ class MyRepositoryMockImpl implements MyRepository { Future registerFcmToken(String token) { return Future.value(); } + + @override + Future updatePushEnabled(bool enabled) async { + await Future.delayed(const Duration(milliseconds: 300)); + return true; + } } diff --git a/lib/features/my/viewmodels/my_viewmodel.dart b/lib/features/my/viewmodels/my_viewmodel.dart index 42a3226..7d39ae2 100644 --- a/lib/features/my/viewmodels/my_viewmodel.dart +++ b/lib/features/my/viewmodels/my_viewmodel.dart @@ -167,4 +167,8 @@ class MyViewModel with ChangeNotifier { await _repository.registerFcmToken(token); } + + Future updatePushEnabled(bool enabled) async { + return _repository.updatePushEnabled(enabled); + } } diff --git a/lib/features/my/views/my_page.dart b/lib/features/my/views/my_page.dart index 90787ef..3a8ac13 100644 --- a/lib/features/my/views/my_page.dart +++ b/lib/features/my/views/my_page.dart @@ -32,14 +32,16 @@ class MyPage extends StatelessWidget { showBackButton: false, actions: const [AlertBadge()], ), - body: SingleChildScrollView( - child: Column( - children: [ - _buildProfileSection(context), - const AppDivider(), - SizedBox(height: 20.h), - _buildMenuList(context), - ], + body: SafeArea( + child: SingleChildScrollView( + child: Column( + children: [ + _buildProfileSection(context), + const AppDivider(), + SizedBox(height: 20.h), + _buildMenuList(context), + ], + ), ), ), ), diff --git a/lib/features/my/views/notification_settings_page.dart b/lib/features/my/views/notification_settings_page.dart index f38dc78..f2b1d23 100644 --- a/lib/features/my/views/notification_settings_page.dart +++ b/lib/features/my/views/notification_settings_page.dart @@ -1,10 +1,13 @@ import 'package:arttrip/core/app_colors.dart'; +import 'package:arttrip/core/config/prefs.dart'; import 'package:arttrip/core/extensions.dart'; +import 'package:arttrip/features/my/viewmodels/my_viewmodel.dart'; import 'package:arttrip/shared/utils/text/arttrip_text.dart'; import 'package:arttrip/shared/widgets/common_appbar.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:provider/provider.dart'; class NotificationSettingsPage extends StatefulWidget { const NotificationSettingsPage({super.key}); @@ -15,9 +18,30 @@ class NotificationSettingsPage extends StatefulWidget { } class _NotificationSettingsPageState extends State { - bool _exhibitInfo = false; - bool _stampIssue = false; - bool _notice = false; + bool _notice = Prefs().pushEnabled; + bool _isUpdating = false; + + Future _onNoticeChanged(bool value) async { + if (_isUpdating) return; + + final previous = _notice; + setState(() { + _notice = value; + _isUpdating = true; + }); + + final vm = context.read(); + final success = await vm.updatePushEnabled(value); + + if (!mounted) return; + if (success) { + await Prefs().setPushEnabled(value); + } + setState(() { + if (!success) _notice = previous; + _isUpdating = false; + }); + } @override Widget build(BuildContext context) { @@ -42,22 +66,10 @@ class _NotificationSettingsPageState extends State { .build() .text(context.l10n.serviceNotificationDesc), SizedBox(height: 16.h), - _buildToggleRow( - label: context.l10n.notifExhibitInfo, - value: _exhibitInfo, - onChanged: (v) => setState(() => _exhibitInfo = v), - ), - SizedBox(height: 20.h), - _buildToggleRow( - label: context.l10n.notifStampIssue, - value: _stampIssue, - onChanged: (v) => setState(() => _stampIssue = v), - ), - SizedBox(height: 20.h), _buildToggleRow( label: context.l10n.notifNotice, value: _notice, - onChanged: (v) => setState(() => _notice = v), + onChanged: _isUpdating ? null : _onNoticeChanged, ), ], ), @@ -77,7 +89,7 @@ class _NotificationSettingsPageState extends State { Widget _buildToggleRow({ required String label, required bool value, - required ValueChanged onChanged, + required ValueChanged? onChanged, }) { return Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index e715283..251ae23 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -94,7 +94,6 @@ "serviceNotification": "서비스 알림", "serviceNotificationDesc": "서비스 이용에 필요한 내용을 알려드립니다.", "notifExhibitInfo": "전시 정보", - "notifStampIssue": "스탬프 발급", "notifNotice": "안내 알림", "badWordDialogTitle": "금칙어가 포함되어있습니다.", "badWordDialogContent": "부적절한 표현이 있어 등록이 제한되었습니다.\n수정 후 다시 시도해 주세요.", From 97cd8882831770d78b48676e6048b3bcda0dea65 Mon Sep 17 00:00:00 2001 From: Ju Date: Mon, 18 May 2026 18:48:13 +0900 Subject: [PATCH 05/17] =?UTF-8?q?feat:=20=EC=95=8C=EB=A6=BC=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=20=EC=A1=B0=ED=9A=8C=20API=20=EC=97=B0=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/core/config/prefs.dart | 12 --------- lib/features/my/data/my_repository.dart | 16 +++++++++++ lib/features/my/data/my_repository_mock.dart | 6 +++++ lib/features/my/viewmodels/my_viewmodel.dart | 4 +++ .../my/views/notification_settings_page.dart | 27 ++++++++++++------- 5 files changed, 44 insertions(+), 21 deletions(-) diff --git a/lib/core/config/prefs.dart b/lib/core/config/prefs.dart index aa52350..7d82b71 100644 --- a/lib/core/config/prefs.dart +++ b/lib/core/config/prefs.dart @@ -68,18 +68,6 @@ class Prefs { } } - // ============================================ - // Notification - // ============================================ - - static const String _pushEnabledKey = 'push_enabled'; - - bool get pushEnabled => prefs.getBool(_pushEnabledKey) ?? true; - - Future setPushEnabled(bool value) async { - await prefs.setBool(_pushEnabledKey, value); - } - // ============================================ // Onboarding Step // ============================================ diff --git a/lib/features/my/data/my_repository.dart b/lib/features/my/data/my_repository.dart index a2a6183..6db6555 100644 --- a/lib/features/my/data/my_repository.dart +++ b/lib/features/my/data/my_repository.dart @@ -36,6 +36,9 @@ abstract class MyRepository { /// fcm token 등록 Future registerFcmToken(String token); + /// 푸시 알림 수신 여부 조회 + Future fetchPushEnabled(); + /// 푸시 알림 수신 여부 변경 Future updatePushEnabled(bool enabled); } @@ -179,6 +182,19 @@ class MyRepositoryImpl implements MyRepository { } } + @override + Future fetchPushEnabled() async { + try { + final response = await _dio.get(ApiEndpoints.mePushEnabled); + final data = response.dataOrNull; + if (data == null) return null; + return (data as Map)['enabled'] as bool?; + } catch (e) { + AppUtil.debugLog('fetchPushEnabled: $e'); + } + return null; + } + @override Future updatePushEnabled(bool enabled) async { try { diff --git a/lib/features/my/data/my_repository_mock.dart b/lib/features/my/data/my_repository_mock.dart index 9117602..20ee004 100644 --- a/lib/features/my/data/my_repository_mock.dart +++ b/lib/features/my/data/my_repository_mock.dart @@ -110,6 +110,12 @@ class MyRepositoryMockImpl implements MyRepository { return Future.value(); } + @override + Future fetchPushEnabled() async { + await Future.delayed(const Duration(milliseconds: 300)); + return true; + } + @override Future updatePushEnabled(bool enabled) async { await Future.delayed(const Duration(milliseconds: 300)); diff --git a/lib/features/my/viewmodels/my_viewmodel.dart b/lib/features/my/viewmodels/my_viewmodel.dart index 7d39ae2..c7c2389 100644 --- a/lib/features/my/viewmodels/my_viewmodel.dart +++ b/lib/features/my/viewmodels/my_viewmodel.dart @@ -168,6 +168,10 @@ class MyViewModel with ChangeNotifier { await _repository.registerFcmToken(token); } + Future fetchPushEnabled() async { + return _repository.fetchPushEnabled(); + } + Future updatePushEnabled(bool enabled) async { return _repository.updatePushEnabled(enabled); } diff --git a/lib/features/my/views/notification_settings_page.dart b/lib/features/my/views/notification_settings_page.dart index f2b1d23..2e40851 100644 --- a/lib/features/my/views/notification_settings_page.dart +++ b/lib/features/my/views/notification_settings_page.dart @@ -1,5 +1,4 @@ import 'package:arttrip/core/app_colors.dart'; -import 'package:arttrip/core/config/prefs.dart'; import 'package:arttrip/core/extensions.dart'; import 'package:arttrip/features/my/viewmodels/my_viewmodel.dart'; import 'package:arttrip/shared/utils/text/arttrip_text.dart'; @@ -18,9 +17,21 @@ class NotificationSettingsPage extends StatefulWidget { } class _NotificationSettingsPageState extends State { - bool _notice = Prefs().pushEnabled; + bool? _notice; bool _isUpdating = false; + @override + void initState() { + super.initState(); + _fetchInitialState(); + } + + Future _fetchInitialState() async { + final enabled = await context.read().fetchPushEnabled(); + if (!mounted) return; + setState(() => _notice = enabled); + } + Future _onNoticeChanged(bool value) async { if (_isUpdating) return; @@ -30,13 +41,9 @@ class _NotificationSettingsPageState extends State { _isUpdating = true; }); - final vm = context.read(); - final success = await vm.updatePushEnabled(value); + final success = await context.read().updatePushEnabled(value); if (!mounted) return; - if (success) { - await Prefs().setPushEnabled(value); - } setState(() { if (!success) _notice = previous; _isUpdating = false; @@ -68,8 +75,10 @@ class _NotificationSettingsPageState extends State { SizedBox(height: 16.h), _buildToggleRow( label: context.l10n.notifNotice, - value: _notice, - onChanged: _isUpdating ? null : _onNoticeChanged, + value: _notice ?? false, + onChanged: (_notice == null || _isUpdating) + ? null + : _onNoticeChanged, ), ], ), From 8c1b9cfb41f4722a658902540b2a22b223bb25d5 Mon Sep 17 00:00:00 2001 From: Ju Date: Mon, 18 May 2026 19:25:44 +0900 Subject: [PATCH 06/17] =?UTF-8?q?chore:=20=EB=A6=B4=EB=A6=AC=EC=A6=88=20?= =?UTF-8?q?=EB=B2=84=EC=A0=84=20=EC=97=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pubspec.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pubspec.yaml b/pubspec.yaml index 0bfeb44..7308a94 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: arttrip description: "ArtTrip - Global Art Exhibition Booking App" publish_to: "none" -version: 1.0.0+1 +version: 1.0.0+10 environment: sdk: ">=3.8.0 <4.0.0" From 72f8c0d60c35136b29f5d5cdbf1f19b3f4e95bd8 Mon Sep 17 00:00:00 2001 From: Ju Date: Fri, 5 Jun 2026 15:09:25 +0900 Subject: [PATCH 07/17] =?UTF-8?q?fix:=20=ED=82=A4=EC=9B=8C=EB=93=9C=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95=20=EC=A0=95=EB=A0=AC=20=EB=B0=8F=20=ED=8F=B0?= =?UTF-8?q?=ED=8A=B8=20=EC=88=98=EC=A0=95,=20=EC=82=AC=EC=9A=A9=EC=9E=90?= =?UTF-8?q?=20=EB=A7=9E=EC=B6=A4=20=EC=B6=94=EC=B2=9C=20=EC=BA=90=EC=8B=B1?= =?UTF-8?q?=EC=B2=98=EB=A6=AC.=20=EC=A6=90=EA=B2=A8=EC=B0=BE=EA=B8=B0=20?= =?UTF-8?q?=EA=B0=B1=EC=8B=A0=20=EB=A1=9C=EC=A7=81=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../exhibit/viewmodels/exhibit_viewmodel.dart | 8 ++ .../exhibit/widgets/exhibit_detail_tab.dart | 13 +-- lib/features/favorite/favorites_page.dart | 26 ++++++ lib/features/home/home_viewmodel.dart | 31 ++++--- lib/features/home/views/curation_view.dart | 3 +- .../home/views/domestic_overseas_view.dart | 87 ++++++++++--------- .../views/personalized_exhibits_view.dart | 8 +- .../home/widgets/today_exhibit_widget.dart | 2 +- .../onboarding/widgets/keyword_chip.dart | 28 +++--- lib/shared/utils/snackbar_utils.dart | 2 +- lib/shared/widgets/main_shell.dart | 5 ++ pubspec.yaml | 2 +- 12 files changed, 135 insertions(+), 80 deletions(-) diff --git a/lib/features/exhibit/viewmodels/exhibit_viewmodel.dart b/lib/features/exhibit/viewmodels/exhibit_viewmodel.dart index aa02206..0ddfb5a 100644 --- a/lib/features/exhibit/viewmodels/exhibit_viewmodel.dart +++ b/lib/features/exhibit/viewmodels/exhibit_viewmodel.dart @@ -11,6 +11,14 @@ class ExhibitViewModel with ChangeNotifier { final Map _favoriteMap = {}; + int _favoritesRefreshTrigger = 0; + int get favoritesRefreshTrigger => _favoritesRefreshTrigger; + + void triggerFavoritesRefresh() { + _favoritesRefreshTrigger++; + notifyListeners(); + } + /// 즐겨찾기 여부 조회 bool isFavorite(int? exhibitId) { return _favoriteMap[exhibitId] ?? false; diff --git a/lib/features/exhibit/widgets/exhibit_detail_tab.dart b/lib/features/exhibit/widgets/exhibit_detail_tab.dart index 96caa67..a0c91c3 100644 --- a/lib/features/exhibit/widgets/exhibit_detail_tab.dart +++ b/lib/features/exhibit/widgets/exhibit_detail_tab.dart @@ -44,11 +44,14 @@ class ExhibitDetailModelTabContent extends StatelessWidget { extraWidget: _buildCopyButton(context, exhibit.hallAddress), ), SizedBox(height: 4.h), - ArtTripText.pretendard() - .body03Regular() - .color(AppColors.textSecondary) - .build() - .text(context.l10n.exhibitAddressNotice), + Align( + alignment: Alignment.center, + child: ArtTripText.pretendard() + .body03Regular() + .color(AppColors.textSecondary) + .build() + .text(context.l10n.exhibitAddressNotice), + ), ], ), ); diff --git a/lib/features/favorite/favorites_page.dart b/lib/features/favorite/favorites_page.dart index abf366e..39ce4c4 100644 --- a/lib/features/favorite/favorites_page.dart +++ b/lib/features/favorite/favorites_page.dart @@ -43,9 +43,15 @@ class _FavoritesPageState extends State { final int _size = 10; int _cursor = 0; + late int _lastRefreshTrigger; + @override void initState() { super.initState(); + final exhibitVM = context.read(); + _lastRefreshTrigger = exhibitVM.favoritesRefreshTrigger; + exhibitVM.addListener(_onFavoritesRefreshTriggered); + final homeVM = context.read(); Future.delayed(Duration.zero, () async { if (mounted) await homeVM.getDomesticRegions(context); @@ -72,10 +78,30 @@ class _FavoritesPageState extends State { @override void dispose() { + context.read().removeListener(_onFavoritesRefreshTriggered); _scrollController.dispose(); super.dispose(); } + void _onFavoritesRefreshTriggered() { + final exhibitVM = context.read(); + + final trigger = exhibitVM.favoritesRefreshTrigger; + if (trigger != _lastRefreshTrigger) { + _lastRefreshTrigger = trigger; + _getFavoriteExhibits(); + return; + } + + if (_exhibits.value != null) { + _exhibits.value = _exhibits.value! + .where( + (e) => e.exhibitId != null && exhibitVM.isFavorite(e.exhibitId), + ) + .toList(); + } + } + Future _getFavoriteExhibits() async { _isLoading.value = true; _cursor = 0; diff --git a/lib/features/home/home_viewmodel.dart b/lib/features/home/home_viewmodel.dart index c610074..6b7dd9b 100644 --- a/lib/features/home/home_viewmodel.dart +++ b/lib/features/home/home_viewmodel.dart @@ -40,8 +40,8 @@ class HomeViewModel with ChangeNotifier { > _weeklyExhibitsBySelectedDate = {}; - /// 개인 맞춤 추천 전시 - final Map>> + /// 개인 맞춤 추천 전시 (해외/국내 -> 국가/지역) + final Map>>> _personalizedExhibits = {}; /// 큐레이션 전시 @@ -70,8 +70,8 @@ class HomeViewModel with ChangeNotifier { Map>>> get todayExhibitRecommendations => _todayExhibitRecommendations; - Map>> get personalizedExhibits => - _personalizedExhibits; + Map>>> + get personalizedExhibits => _personalizedExhibits; Map>>>> get weeklyExhibitsBySelectedDate => _weeklyExhibitsBySelectedDate; @@ -310,16 +310,19 @@ class HomeViewModel with ChangeNotifier { /// 사용자 맞춤 전시 리스트 조회 Future getPersonalizedExhibits() async { /// 캐싱 처리 - if (_personalizedExhibits[_locationType]?.status == AsyncStatus.success) { - final oldState = _personalizedExhibits[_locationType]!; - _personalizedExhibits[_locationType] = AsyncState.success( - List.from(oldState.data!), - ); + if (_personalizedExhibits[_locationType]?[_area[_locationType]]?.status == + AsyncStatus.success) { + final oldState = + _personalizedExhibits[_locationType]![_area[_locationType]]!; + _personalizedExhibits[_locationType]![_area[_locationType]!] = + AsyncState.success(List.from(oldState.data!)); notifyListeners(); return; } - _personalizedExhibits[_locationType] = const AsyncState.loading(); + _personalizedExhibits[_locationType] ??= {}; + _personalizedExhibits[_locationType]![_area[_locationType]!] = + const AsyncState.loading(); notifyListeners(); try { @@ -329,11 +332,13 @@ class HomeViewModel with ChangeNotifier { region: isDomestic ? _area[_locationType] : null, ); - _personalizedExhibits[_locationType] = AsyncState.success(result); + _personalizedExhibits[_locationType]![_area[_locationType]!] = + AsyncState.success(result); exhibitVM.initializeFromExhibits(result); } catch (e) { AppUtil.debugLog('getPersonalizedExhibits error: $e'); - _personalizedExhibits[_locationType] = const AsyncState.error(); + _personalizedExhibits[_locationType]![_area[_locationType]!] = + const AsyncState.error(); } notifyListeners(); } @@ -406,6 +411,8 @@ class HomeViewModel with ChangeNotifier { ); } catch (e) { AppUtil.debugLog('getCurations error: $e'); + _curations[_locationType]![_area[_locationType]!] = + const AsyncState.error(); } } diff --git a/lib/features/home/views/curation_view.dart b/lib/features/home/views/curation_view.dart index b482214..78e4e26 100644 --- a/lib/features/home/views/curation_view.dart +++ b/lib/features/home/views/curation_view.dart @@ -162,7 +162,8 @@ class _CurationViewState extends State { ), ], ), - ArtTripText.pretendard().body01Regular().build().text(subtitle), + if (subtitle.isNotEmpty) + ArtTripText.pretendard().body01Regular().build().text(subtitle), ], ), ), diff --git a/lib/features/home/views/domestic_overseas_view.dart b/lib/features/home/views/domestic_overseas_view.dart index c98bcc4..5ebeeaa 100644 --- a/lib/features/home/views/domestic_overseas_view.dart +++ b/lib/features/home/views/domestic_overseas_view.dart @@ -10,6 +10,7 @@ import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:provider/provider.dart'; import 'package:shimmer_animation/shimmer_animation.dart'; +/// 해외/국내별 국가/지역 선택 탭바 뷰 class DomesticOverseasView extends StatefulWidget { const DomesticOverseasView({super.key}); @@ -35,20 +36,20 @@ class _DomesticOverseasViewState extends State { @override Widget build(BuildContext context) { return SliverToBoxAdapter( - child: SizedBox( - height: 64.h, - child: Selector>>( - selector: (_, vm) => vm.overseasCountries, - builder: (context, state, _) { - return AsyncView( - state: state, - onData: (data) { - if (data.isEmpty) return const SizedBox.shrink(); + child: Selector>>( + selector: (_, vm) => vm.overseasCountries, + builder: (context, state, _) { + return AsyncView( + state: state, + onData: (data) { + if (data.isEmpty) return const SizedBox.shrink(); - final itemCount = data.length; - _itemKeys = List.generate(itemCount, (_) => GlobalKey()); + final itemCount = data.length; + _itemKeys = List.generate(itemCount, (_) => GlobalKey()); - return ListView.separated( + return SizedBox( + height: 64.h, + child: ListView.separated( shrinkWrap: true, itemCount: itemCount, scrollDirection: Axis.horizontal, @@ -66,39 +67,39 @@ class _DomesticOverseasViewState extends State { data[index], ); }, - ); - }, - onLoading: () { - return Shimmer( - duration: const Duration( - milliseconds: AppConsts.shimmerDurationMs, - ), - interval: const Duration( - milliseconds: AppConsts.shimmerIntervalMs, - ), - child: SizedBox( - height: 64.h, - child: ListView.separated( - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - scrollDirection: Axis.horizontal, - padding: EdgeInsets.symmetric( - horizontal: 24.w, - vertical: 16.h, - ), - itemCount: 5, - separatorBuilder: (context, index) => - SizedBox(width: 8.w), - itemBuilder: (context, index) { - return const ShimmerSkeletonItem(width: 76, height: 32); - }, + ), + ); + }, + onLoading: () { + return Shimmer( + duration: const Duration( + milliseconds: AppConsts.shimmerDurationMs, + ), + interval: const Duration( + milliseconds: AppConsts.shimmerIntervalMs, + ), + child: SizedBox( + height: 64.h, + child: ListView.separated( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + scrollDirection: Axis.horizontal, + padding: EdgeInsets.symmetric( + horizontal: 24.w, + vertical: 16.h, ), + itemCount: 5, + separatorBuilder: (context, index) => SizedBox(width: 8.w), + itemBuilder: (context, index) { + return const ShimmerSkeletonItem(width: 76, height: 32); + }, ), - ); - }, - ); - }, - ), + ), + ); + }, + onError: ({error}) => const SizedBox.shrink(), + ); + }, ), ); } diff --git a/lib/features/home/views/personalized_exhibits_view.dart b/lib/features/home/views/personalized_exhibits_view.dart index 1576b7d..627271e 100644 --- a/lib/features/home/views/personalized_exhibits_view.dart +++ b/lib/features/home/views/personalized_exhibits_view.dart @@ -34,7 +34,8 @@ class _PersonalizedExhibitsViewState extends State { return SliverToBoxAdapter( child: Selector>>( selector: (_, vm) => - vm.personalizedExhibits[vm.locationType] ?? + vm.personalizedExhibits[vm.locationType]?[vm.area[vm + .locationType]] ?? const AsyncState.loading(), builder: (context, state, _) { return AsyncView( @@ -42,6 +43,7 @@ class _PersonalizedExhibitsViewState extends State { onData: (data) { if (data.isEmpty) return const SizedBox.shrink(); + final bool isDomestic = context.read().isDomestic; return Column( crossAxisAlignment: CrossAxisAlignment.start, spacing: 12.h, @@ -83,7 +85,9 @@ class _PersonalizedExhibitsViewState extends State { SizedBox(width: 8.w), itemBuilder: (_, index) { final item = data[index]; - final location = item.countryName ?? item.regionName; + final location = !isDomestic + ? item.countryName ?? item.regionName + : item.regionName; return GestureDetector( onTap: () => Routes.push( context, diff --git a/lib/features/home/widgets/today_exhibit_widget.dart b/lib/features/home/widgets/today_exhibit_widget.dart index 7fe7e07..6920c9b 100644 --- a/lib/features/home/widgets/today_exhibit_widget.dart +++ b/lib/features/home/widgets/today_exhibit_widget.dart @@ -130,7 +130,7 @@ class TodayExhibitWidget extends StatelessWidget { .color(AppColors.textWhite) .build() .text(item.hallName!), - if (item.exhibitPeriod != null) + if (item.exhibitPeriod?.isNotEmpty == true) ArtTripText.pretendard() .body02Regular() .color(AppColors.textWhite) diff --git a/lib/features/onboarding/widgets/keyword_chip.dart b/lib/features/onboarding/widgets/keyword_chip.dart index bb4d335..fbd2400 100644 --- a/lib/features/onboarding/widgets/keyword_chip.dart +++ b/lib/features/onboarding/widgets/keyword_chip.dart @@ -1,3 +1,4 @@ +import 'package:arttrip/shared/utils/text/arttrip_text.dart'; import 'package:flutter/material.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; @@ -24,7 +25,6 @@ class KeywordModelChip extends StatelessWidget { return GestureDetector( onTap: onTap, child: Container( - width: 100.w, height: 40.h, padding: EdgeInsets.symmetric(horizontal: 20.w, vertical: 12.h), decoration: BoxDecoration( @@ -34,19 +34,19 @@ class KeywordModelChip extends StatelessWidget { ? null : Border.all(color: _unselectedBorderColor, width: 1), ), - alignment: Alignment.center, - child: FittedBox( - fit: BoxFit.scaleDown, - child: Text( - label, - style: TextStyle( - fontSize: 14.sp, - fontWeight: isSelected ? FontWeight.w700 : FontWeight.w300, - color: isSelected ? Colors.white : _textColor, - ), - textAlign: TextAlign.center, - ), - ), + child: isSelected + ? ArtTripText.pretendard() + .body01Bold() + .textAlign(TextAlign.center) + .color(Colors.white) + .build() + .text(label) + : ArtTripText.pretendard() + .body01Light() + .textAlign(TextAlign.center) + .color(_textColor) + .build() + .text(label), ), ); } diff --git a/lib/shared/utils/snackbar_utils.dart b/lib/shared/utils/snackbar_utils.dart index 016e098..0236e72 100644 --- a/lib/shared/utils/snackbar_utils.dart +++ b/lib/shared/utils/snackbar_utils.dart @@ -162,7 +162,7 @@ class SnackBarUtils { message, style: TextStyle( color: textColor, - fontSize: 14, + fontSize: 14.sp, fontWeight: FontWeight.w500, ), ), diff --git a/lib/shared/widgets/main_shell.dart b/lib/shared/widgets/main_shell.dart index 893c65c..aa4f733 100644 --- a/lib/shared/widgets/main_shell.dart +++ b/lib/shared/widgets/main_shell.dart @@ -1,6 +1,8 @@ +import 'package:arttrip/features/exhibit/viewmodels/exhibit_viewmodel.dart'; import 'package:arttrip/shared/widgets/bottom_nav_bar.dart'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; +import 'package:provider/provider.dart'; /// GNB ShellRoute용 Shell 위젯 /// @@ -34,6 +36,9 @@ class _MainShellState extends State { } void _onTap(int index) { + if (index == 3) { + context.read().triggerFavoritesRefresh(); + } widget.navigationShell.goBranch( index, initialLocation: index == widget.navigationShell.currentIndex, diff --git a/pubspec.yaml b/pubspec.yaml index 7308a94..45f6d87 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: arttrip description: "ArtTrip - Global Art Exhibition Booking App" publish_to: "none" -version: 1.0.0+10 +version: 1.0.0+11 environment: sdk: ">=3.8.0 <4.0.0" From c3d9b48b94ff542a38332ee325ad7638e4bc9dc7 Mon Sep 17 00:00:00 2001 From: Ju Date: Fri, 5 Jun 2026 16:15:23 +0900 Subject: [PATCH 08/17] =?UTF-8?q?fix:=20=ED=99=88=20>=20=EC=9D=B4=EB=B2=88?= =?UTF-8?q?=EC=A3=BC=20=EC=A0=84=EC=8B=9C=EC=97=90=20=EA=B5=AD=EA=B0=80?= =?UTF-8?q?=EA=B0=80=20=EC=A0=84=EC=B2=B4=20=EC=9D=B8=20=EA=B2=BD=EC=9A=B0?= =?UTF-8?q?,=20=EA=B5=AD=EA=B0=80=20=EC=B6=94=EA=B0=80.=20=EA=B5=AD?= =?UTF-8?q?=EB=82=B4=EC=9D=B8=20=EA=B2=BD=EC=9A=B0=EC=97=94,=20=EC=A7=80?= =?UTF-8?q?=EC=97=AD.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../exhibit/data/models/exhibit_model.dart | 4 +++ lib/features/favorite/favorites_page.dart | 10 +++++- .../home/pages/calendar_result_page.dart | 5 ++- .../home/pages/curation_detail_page.dart | 10 ++++-- .../home/pages/genre_detail_page.dart | 6 ++-- .../home/pages/region_detail_page.dart | 2 +- .../home/pages/weekly_detail_page.dart | 2 +- .../home/views/genre_exhibits_view.dart | 9 ++++- .../views/weekly_exhibits_schedule_view.dart | 14 ++++++-- .../map/widgets/map_bottom_sheet.dart | 5 ++- lib/features/search/views/search_page.dart | 9 ++++- lib/shared/widgets/exhibit_list_item.dart | 33 +++++++++++++++---- 12 files changed, 89 insertions(+), 20 deletions(-) diff --git a/lib/features/exhibit/data/models/exhibit_model.dart b/lib/features/exhibit/data/models/exhibit_model.dart index bb3695d..247871a 100644 --- a/lib/features/exhibit/data/models/exhibit_model.dart +++ b/lib/features/exhibit/data/models/exhibit_model.dart @@ -17,6 +17,10 @@ abstract class ExhibitModel with _$ExhibitModel { String? hallName, String? countryName, String? regionName, + + /// 즐겨찾기 관련 + int? favoriteId, + @Default(false) bool active, @JsonKey(name: 'isFavorite') @Default(false) bool favorite, }) = _ExhibitModel; diff --git a/lib/features/favorite/favorites_page.dart b/lib/features/favorite/favorites_page.dart index 39ce4c4..413a1b7 100644 --- a/lib/features/favorite/favorites_page.dart +++ b/lib/features/favorite/favorites_page.dart @@ -78,7 +78,9 @@ class _FavoritesPageState extends State { @override void dispose() { - context.read().removeListener(_onFavoritesRefreshTriggered); + context.read().removeListener( + _onFavoritesRefreshTriggered, + ); _scrollController.dispose(); super.dispose(); } @@ -119,6 +121,8 @@ class _FavoritesPageState extends State { _hasNext.value = result?.hasNext ?? false; _cursor = result?.nextCursor ?? 0; _isLoading.value = false; + + exhibitVM.initializeFromExhibits(result?.favorites ?? []); } Future _loadMoreExhibits() async { @@ -137,6 +141,8 @@ class _FavoritesPageState extends State { _hasNext.value = result?.hasNext ?? false; _cursor = result?.nextCursor ?? 0; _loadingMore.value = false; + + exhibitVM.initializeFromExhibits(result?.favorites ?? []); } @override @@ -287,9 +293,11 @@ class _FavoritesPageState extends State { final ExhibitModel item = exhibits[index]; return ExhibitListItem( item: item, + isDomestic: _isDomestic.value, showArea: _selectedArea.value == context.l10n.allItems, + forceFavorite: true, ); }, ), diff --git a/lib/features/home/pages/calendar_result_page.dart b/lib/features/home/pages/calendar_result_page.dart index f502c5a..fb93cb9 100644 --- a/lib/features/home/pages/calendar_result_page.dart +++ b/lib/features/home/pages/calendar_result_page.dart @@ -232,7 +232,10 @@ class _CalendarResultPageState extends State { ); } final ExhibitModel item = exhibits[index]; - return ExhibitListItem(item: item); + return ExhibitListItem( + item: item, + isDomestic: false, + ); }, ); }, diff --git a/lib/features/home/pages/curation_detail_page.dart b/lib/features/home/pages/curation_detail_page.dart index 7b14985..7b3927f 100644 --- a/lib/features/home/pages/curation_detail_page.dart +++ b/lib/features/home/pages/curation_detail_page.dart @@ -1,4 +1,5 @@ import 'package:arttrip/core/app_utils.dart'; +import 'package:arttrip/core/enum.dart'; import 'package:arttrip/core/extensions.dart'; import 'package:arttrip/features/exhibit/data/models/exhibit_filter_model.dart'; import 'package:arttrip/features/exhibit/data/models/exhibit_model.dart'; @@ -123,7 +124,9 @@ class _CurationDetailPageState extends State { } else if (exhibits.isEmpty) { return const NoExhibitsView(); } - + final bool isDomestic = + context.read().locationType == + LocationType.domestic; return ValueListenableBuilder( valueListenable: _loadingMore, builder: (context, loadingMore, child) { @@ -147,7 +150,10 @@ class _CurationDetailPageState extends State { } final ExhibitModel item = exhibits[index]; - return ExhibitListItem(item: item); + return ExhibitListItem( + item: item, + isDomestic: isDomestic, + ); }, ); }, diff --git a/lib/features/home/pages/genre_detail_page.dart b/lib/features/home/pages/genre_detail_page.dart index 5cb424a..28ebcd1 100644 --- a/lib/features/home/pages/genre_detail_page.dart +++ b/lib/features/home/pages/genre_detail_page.dart @@ -188,7 +188,6 @@ class _GenreDetailPageState extends State { } else if (exhibits.isEmpty) { return const NoExhibitsView(); } - return ValueListenableBuilder( valueListenable: _loadingMore, builder: (context, loadingMore, child) { @@ -211,7 +210,10 @@ class _GenreDetailPageState extends State { ); } final ExhibitModel item = exhibits[index]; - return ExhibitListItem(item: item); + return ExhibitListItem( + item: item, + isDomestic: widget.isDomestic, + ); }, ); }, diff --git a/lib/features/home/pages/region_detail_page.dart b/lib/features/home/pages/region_detail_page.dart index b37768a..9172484 100644 --- a/lib/features/home/pages/region_detail_page.dart +++ b/lib/features/home/pages/region_detail_page.dart @@ -204,7 +204,7 @@ class _RegionDetailPageState extends State { } final item = exhibits[index]; - return ExhibitListItem(item: item); + return ExhibitListItem(item: item, isDomestic: true); }, ); }, diff --git a/lib/features/home/pages/weekly_detail_page.dart b/lib/features/home/pages/weekly_detail_page.dart index 80ab858..57f6563 100644 --- a/lib/features/home/pages/weekly_detail_page.dart +++ b/lib/features/home/pages/weekly_detail_page.dart @@ -183,7 +183,7 @@ class _WeeklyDetailPageState extends State { } final ExhibitModel item = exhibits[index]; - return ExhibitListItem(item: item); + return ExhibitListItem(item: item, isDomestic: _isDomestic); }, ), ); diff --git a/lib/features/home/views/genre_exhibits_view.dart b/lib/features/home/views/genre_exhibits_view.dart index 1da6c3c..944de89 100644 --- a/lib/features/home/views/genre_exhibits_view.dart +++ b/lib/features/home/views/genre_exhibits_view.dart @@ -104,6 +104,10 @@ class _GenreExhibitsViewState extends State { .locationType]]; return _buildNoExhibitions(selectedGenre); } + final homeViewModel = context.read(); + final isDomestic = + homeViewModel.locationType == + LocationType.domestic; return ListView.separated( physics: const NeverScrollableScrollPhysics(), shrinkWrap: true, @@ -113,7 +117,10 @@ class _GenreExhibitsViewState extends State { SizedBox(height: 8.h), itemBuilder: (context, index) { final item = data[index]; - return ExhibitListItem(item: item); + return ExhibitListItem( + item: item, + isDomestic: isDomestic, + ); }, ); }, diff --git a/lib/features/home/views/weekly_exhibits_schedule_view.dart b/lib/features/home/views/weekly_exhibits_schedule_view.dart index 0ab2981..12b716e 100644 --- a/lib/features/home/views/weekly_exhibits_schedule_view.dart +++ b/lib/features/home/views/weekly_exhibits_schedule_view.dart @@ -2,6 +2,7 @@ import 'package:arttrip/core/app_assets.dart'; import 'package:arttrip/core/app_colors.dart'; import 'package:arttrip/core/app_consts.dart'; import 'package:arttrip/core/app_utils.dart'; +import 'package:arttrip/core/enum.dart'; import 'package:arttrip/core/extensions.dart'; import 'package:arttrip/features/exhibit/data/models/exhibit_model.dart'; import 'package:arttrip/features/home/home_viewmodel.dart'; @@ -60,12 +61,21 @@ class _WeeklyExhibitsScheduleViewState if (data.isEmpty) { return _buildNoExhibitions(); } - + final homeVM = context.read(); + final bool isDomestic = + homeVM.locationType == LocationType.domestic; + final bool isAllItems = + homeVM.area[homeVM.locationType] == + context.l10n.allItems; return Column( spacing: 8.h, children: List.generate(data.length, (index) { final item = data[index]; - return ExhibitListItem(item: item); + return ExhibitListItem( + item: item, + isDomestic: isDomestic, + showArea: isAllItems ? true : false, + ); }), ); }, diff --git a/lib/features/map/widgets/map_bottom_sheet.dart b/lib/features/map/widgets/map_bottom_sheet.dart index 8834356..ffbc4f8 100644 --- a/lib/features/map/widgets/map_bottom_sheet.dart +++ b/lib/features/map/widgets/map_bottom_sheet.dart @@ -1,6 +1,7 @@ import 'package:arttrip/core/app_colors.dart'; import 'package:arttrip/core/enum.dart'; import 'package:arttrip/core/extensions.dart'; +import 'package:arttrip/features/home/home_viewmodel.dart'; import 'package:arttrip/features/map/viewmodels/map_viewmodel.dart'; import 'package:arttrip/shared/utils/text/arttrip_text.dart'; import 'package:arttrip/shared/widgets/exhibit_list_item.dart'; @@ -52,6 +53,8 @@ class MapBottomSheet extends StatelessWidget { ), child: Consumer( builder: (context, vm, _) { + final homeVM = context.read(); + final isDomestic = homeVM.locationType == LocationType.domestic; final exhibits = vm.currentExhibits; final count = vm.exhibitTotalCount; final isLoading = @@ -111,6 +114,7 @@ class MapBottomSheet extends StatelessWidget { ), child: ExhibitListItem( item: exhibits[index], + isDomestic: isDomestic, ), ); }, @@ -156,7 +160,6 @@ class MapBottomSheet extends StatelessWidget { }, ); } - } class _HandleHeaderDelegate extends SliverPersistentHeaderDelegate { diff --git a/lib/features/search/views/search_page.dart b/lib/features/search/views/search_page.dart index 2d93c5c..02914c8 100644 --- a/lib/features/search/views/search_page.dart +++ b/lib/features/search/views/search_page.dart @@ -1,6 +1,8 @@ import 'package:arttrip/core/app_colors.dart'; +import 'package:arttrip/core/enum.dart'; import 'package:arttrip/core/extensions.dart'; import 'package:arttrip/features/exhibit/data/models/exhibit_model.dart'; +import 'package:arttrip/features/home/home_viewmodel.dart'; import 'package:arttrip/features/onboarding/data/models/keyword_model.dart'; import 'package:arttrip/features/search/data/models/search_history_model.dart'; import 'package:arttrip/features/search/viewmodels/search_viewmodel.dart'; @@ -132,11 +134,16 @@ class _SearchPageState extends State { state: state, onData: (exhibits) { if (exhibits.isEmpty) return _buildEmptyState(); + final homeVM = context.read(); + final isDomestic = homeVM.locationType == LocationType.domestic; return ListView.separated( padding: EdgeInsets.symmetric(horizontal: 24.w, vertical: 16.h), itemCount: exhibits.length, separatorBuilder: (_, _) => SizedBox(height: 16.h), - itemBuilder: (_, index) => ExhibitListItem(item: exhibits[index]), + itemBuilder: (_, index) => ExhibitListItem( + item: exhibits[index], + isDomestic: isDomestic, + ), ); }, ); diff --git a/lib/shared/widgets/exhibit_list_item.dart b/lib/shared/widgets/exhibit_list_item.dart index 00b3eeb..c250721 100644 --- a/lib/shared/widgets/exhibit_list_item.dart +++ b/lib/shared/widgets/exhibit_list_item.dart @@ -14,10 +14,20 @@ import 'package:flutter_svg/svg.dart'; import 'package:provider/provider.dart'; class ExhibitListItem extends StatelessWidget { - const ExhibitListItem({super.key, required this.item, this.showArea = false}); + const ExhibitListItem({ + super.key, + required this.item, + required this.isDomestic, + this.showArea = false, + this.forceFavorite = false, + }); final ExhibitModel item; final bool showArea; + final bool isDomestic; + + /// 즐겨찾기 여부를 강제로 보여줄지 여부 (ex. 즐겨찾기 페이지에서는 항상 true) + final bool forceFavorite; @override Widget build(BuildContext context) { @@ -71,11 +81,13 @@ class ExhibitListItem extends StatelessWidget { ); exhibitViewModel.updateFavoriteExhibit( item.exhibitId, - !isFavorite, + forceFavorite ? false : !isFavorite, ); }, child: SvgPicture.asset( - AppAssets.icLikeCircle(isLiked: isFavorite), + AppAssets.icLikeCircle( + isLiked: forceFavorite ? true : isFavorite, + ), width: 24.w, height: 24.w, ), @@ -99,10 +111,17 @@ class ExhibitListItem extends StatelessWidget { .body01Regular() .color(const Color(0xFF7859FF)) .build() - .text(item.countryName ?? item.regionName ?? ''), - ArtTripText.pretendard().body01Bold().build().text( - item.title ?? '', - ), + .text( + !isDomestic + ? (item.countryName ?? item.regionName) ?? '' + : item.regionName ?? '', + ), + if (item.title?.isNotEmpty == true) + ArtTripText.pretendard() + .body01Bold() + .ellipsis(2) + .build() + .text(item.title!), Column( crossAxisAlignment: CrossAxisAlignment.start, spacing: 2.h, From 5ab71fc335e28b4bc2da83ed4d8728404b2d7cb4 Mon Sep 17 00:00:00 2001 From: Ju Date: Tue, 9 Jun 2026 18:52:00 +0900 Subject: [PATCH 09/17] =?UTF-8?q?fix:=20=EC=A6=90=EA=B2=A8=EC=B0=BE?= =?UTF-8?q?=EA=B8=B0=20=ED=95=84=ED=84=B0=20=ED=95=B4=EC=99=B8/=EA=B5=AD?= =?UTF-8?q?=EB=82=B4=20=EB=B3=84=EB=8F=84=20=EC=84=A0=ED=83=9D=EB=90=98?= =?UTF-8?q?=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../exhibit/data/exhibit_repository.dart | 4 +- lib/features/favorite/favorites_page.dart | 94 ++++++++++--------- 2 files changed, 50 insertions(+), 48 deletions(-) diff --git a/lib/features/exhibit/data/exhibit_repository.dart b/lib/features/exhibit/data/exhibit_repository.dart index ee7e387..c22d2be 100644 --- a/lib/features/exhibit/data/exhibit_repository.dart +++ b/lib/features/exhibit/data/exhibit_repository.dart @@ -319,8 +319,8 @@ class ExhibitRepositoryImpl implements ExhibitRepository { }) async { try { final queryParams = { - 'country': country, - 'region': region, + 'country': ?country, + 'region': ?region, 'sortType': sortType, 'cursor': cursor, 'size': size, diff --git a/lib/features/favorite/favorites_page.dart b/lib/features/favorite/favorites_page.dart index 413a1b7..439b2a1 100644 --- a/lib/features/favorite/favorites_page.dart +++ b/lib/features/favorite/favorites_page.dart @@ -21,6 +21,7 @@ import 'package:flutter_svg/flutter_svg.dart'; import 'package:go_router/go_router.dart'; import 'package:provider/provider.dart'; +/// 즐겨찾기 페이지 class FavoritesPage extends StatefulWidget { const FavoritesPage({super.key}); @@ -32,8 +33,8 @@ class _FavoritesPageState extends State { final ScrollController _scrollController = ScrollController(); final ValueNotifier?> _exhibits = ValueNotifier([]); final ValueNotifier _sortType = ValueNotifier(SortType.latest); - final ValueNotifier _selectedArea = ValueNotifier(null); - final ValueNotifier _isDomestic = ValueNotifier(true); + final ValueNotifier _selectedOverseasCountry = ValueNotifier(null); + final ValueNotifier _selectedDomesticArea = ValueNotifier(null); final ValueNotifier _isLoading = ValueNotifier(true); // 전체 로딩 상태 final ValueNotifier _hasNext = ValueNotifier(true); // 다음 페이지 존재 여부 @@ -113,8 +114,8 @@ class _FavoritesPageState extends State { cursor: _cursor, size: _size, sortType: _sortType.value.type, - country: _isDomestic.value ? null : _selectedArea.value, - region: _isDomestic.value ? _selectedArea.value : null, + country: _selectedOverseasCountry.value, + region: _selectedDomesticArea.value, ); _exhibits.value = result?.favorites; @@ -134,8 +135,8 @@ class _FavoritesPageState extends State { cursor: _cursor, size: _size, sortType: _sortType.value.type, - country: _isDomestic.value ? null : _selectedArea.value, - region: _isDomestic.value ? _selectedArea.value : null, + country: _selectedOverseasCountry.value, + region: _selectedDomesticArea.value, ); _exhibits.value = [...?_exhibits.value, ...?result?.favorites]; _hasNext.value = result?.hasNext ?? false; @@ -293,10 +294,8 @@ class _FavoritesPageState extends State { final ExhibitModel item = exhibits[index]; return ExhibitListItem( item: item, - isDomestic: _isDomestic.value, - showArea: - _selectedArea.value == - context.l10n.allItems, + isDomestic: false, + showArea: true, forceFavorite: true, ); }, @@ -316,14 +315,14 @@ class _FavoritesPageState extends State { } void _showFilterBottomSheet() { - final ValueNotifier tempArea = ValueNotifier(_selectedArea.value); - final ValueNotifier tempIsDomestic = ValueNotifier(_isDomestic.value); + final ValueNotifier tempOverseasCountry = ValueNotifier( + _selectedOverseasCountry.value, + ); + final ValueNotifier tempDomesticArea = ValueNotifier( + _selectedDomesticArea.value, + ); final ValueNotifier isApplyEnabled = ValueNotifier(false); - void updateApplyEnabled() { - isApplyEnabled.value = tempArea.value?.isNotEmpty == true; - } - showModalBottomSheet( context: context, useRootNavigator: true, @@ -361,11 +360,11 @@ class _FavoritesPageState extends State { bottom: 20.h, ), child: ValueListenableBuilder( - valueListenable: tempIsDomestic, - builder: (context, isDomestic, child) { + valueListenable: tempOverseasCountry, + builder: (context, overseasCountry, child) { return ValueListenableBuilder( - valueListenable: tempArea, - builder: (context, selectedArea, child) { + valueListenable: tempDomesticArea, + builder: (context, domesticArea, child) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -377,9 +376,7 @@ class _FavoritesPageState extends State { ArtTripText.pretendard() .body01Bold() .build() - .text( - context.l10n.oversea, - ), + .text(context.l10n.oversea), Selector>>( selector: (_, vm) => vm.overseasCountries, builder: (context, overseasCountries, _) { @@ -394,13 +391,15 @@ class _FavoritesPageState extends State { (index) { final item = data[index]; final isSelected = - selectedArea == item && - !isDomestic; + overseasCountry == item || + (overseasCountry == null && + index == 0); + return GestureDetector( onTap: () { - tempArea.value = item; - tempIsDomestic.value = false; - updateApplyEnabled(); + tempOverseasCountry.value = + item; + isApplyEnabled.value = true; }, child: Container( padding: EdgeInsets.symmetric( @@ -464,9 +463,7 @@ class _FavoritesPageState extends State { ArtTripText.pretendard() .body01Bold() .build() - .text( - context.l10n.domestic, - ), + .text(context.l10n.domestic), Selector< HomeViewModel, AsyncState> @@ -498,13 +495,19 @@ class _FavoritesPageState extends State { (index) { final item = tempList[index]; final isSelected = - selectedArea == item.region && - isDomestic; + domesticArea == item.region || + (domesticArea == null && + index == 0); + return GestureDetector( onTap: () { - tempArea.value = item.region; - tempIsDomestic.value = true; - updateApplyEnabled(); + if (index == 0) { + tempDomesticArea.value = null; + } else { + tempDomesticArea.value = + item.region; + } + isApplyEnabled.value = true; }, child: Container( padding: EdgeInsets.symmetric( @@ -565,8 +568,9 @@ class _FavoritesPageState extends State { /// 전체 해제 GestureDetector( onTap: () { - tempArea.value = null; - updateApplyEnabled(); + tempOverseasCountry.value = null; + tempDomesticArea.value = null; + isApplyEnabled.value = true; }, child: ColoredBox( color: Colors.transparent, @@ -596,23 +600,21 @@ class _FavoritesPageState extends State { child: ElevatedButton( style: ElevatedButton.styleFrom( backgroundColor: AppColors.primary300, - padding: EdgeInsets.symmetric( - vertical: 17.h, - ), + padding: EdgeInsets.symmetric(vertical: 17.h), elevation: 0, shadowColor: Colors.transparent, disabledBackgroundColor: AppColors.gray100, overlayColor: Colors.transparent, shape: RoundedRectangleBorder( - borderRadius: BorderRadiusGeometry.circular( - 12.r, - ), + borderRadius: BorderRadiusGeometry.circular(12.r), ), ), onPressed: enabled ? () { - _selectedArea.value = tempArea.value; - _isDomestic.value = tempIsDomestic.value; + _selectedOverseasCountry.value = + tempOverseasCountry.value; + _selectedDomesticArea.value = + tempDomesticArea.value; _getFavoriteExhibits(); context.pop(); } From ef98d1af54d76a709dcd6f3f2a1b9e10b83b86c9 Mon Sep 17 00:00:00 2001 From: Ju Date: Tue, 9 Jun 2026 18:54:18 +0900 Subject: [PATCH 10/17] =?UTF-8?q?fix:=20=EC=A7=80=EB=8F=84=ED=83=AD?= =?UTF-8?q?=EC=9D=98=20=EA=B5=AD=EA=B0=80=EB=AA=A9=EB=A1=9D=20=EC=A4=91,?= =?UTF-8?q?=20=ED=95=9C=EA=B5=AD=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20?= =?UTF-8?q?=EC=A0=84=EC=B2=B4=20=ED=95=AD=EB=AA=A9=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/features/map/widgets/map_category_bar.dart | 3 ++- lib/l10n/app_ko.arb | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/lib/features/map/widgets/map_category_bar.dart b/lib/features/map/widgets/map_category_bar.dart index 7032e08..de03723 100644 --- a/lib/features/map/widgets/map_category_bar.dart +++ b/lib/features/map/widgets/map_category_bar.dart @@ -17,7 +17,8 @@ class MapCategoryBar extends StatelessWidget { return Consumer( builder: (context, vm, _) { final isOpen = vm.isDropdownOpen; - final allCountries = [context.l10n.domestic, ...vm.countries]; + final allCountries = [context.l10n.korea, ...vm.countries] + ..removeWhere((c) => c == context.l10n.allItems); return Container( decoration: BoxDecoration( diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index 251ae23..302e715 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -17,6 +17,7 @@ "tempNickname": "임시닉네임", "oversea": "해외", "domestic": "국내", + "korea": "한국", "domesticExhibition": "국내전시", "internationalExhibition": "해외전시", "allItems": "전체", From 5d8a2ceafb25b3a806c2afa03cfc2f39a3fc6e9a Mon Sep 17 00:00:00 2001 From: Ju Date: Tue, 9 Jun 2026 19:44:03 +0900 Subject: [PATCH 11/17] =?UTF-8?q?fix:=20=EC=A6=90=EA=B2=A8=EC=B0=BE?= =?UTF-8?q?=EA=B8=B0=20=ED=99=94=EB=A9=B4=20=EC=A2=8B=EC=95=84=EC=9A=94=20?= =?UTF-8?q?=EC=83=81=ED=83=9C=20=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8=20?= =?UTF-8?q?=EC=A0=95=EC=83=81=20=EA=B0=B1=EC=8B=A0=EB=90=98=EC=A7=80=20?= =?UTF-8?q?=EC=95=8A=EB=8A=94=20=EC=98=A4=EB=A5=98=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../exhibit/data/models/exhibit_model.dart | 4 ++++ .../exhibit/viewmodels/exhibit_viewmodel.dart | 6 +++-- lib/shared/widgets/exhibit_list_item.dart | 23 ++++++++++++++----- 3 files changed, 25 insertions(+), 8 deletions(-) diff --git a/lib/features/exhibit/data/models/exhibit_model.dart b/lib/features/exhibit/data/models/exhibit_model.dart index 247871a..eb7651d 100644 --- a/lib/features/exhibit/data/models/exhibit_model.dart +++ b/lib/features/exhibit/data/models/exhibit_model.dart @@ -13,10 +13,14 @@ abstract class ExhibitModel with _$ExhibitModel { String? title, String? posterUrl, String? status, + String? exhibitStatus, String? exhibitPeriod, String? hallName, + String? exhibitHallName, String? countryName, + String? country, String? regionName, + String? region, /// 즐겨찾기 관련 int? favoriteId, diff --git a/lib/features/exhibit/viewmodels/exhibit_viewmodel.dart b/lib/features/exhibit/viewmodels/exhibit_viewmodel.dart index 0ddfb5a..0bc5c89 100644 --- a/lib/features/exhibit/viewmodels/exhibit_viewmodel.dart +++ b/lib/features/exhibit/viewmodels/exhibit_viewmodel.dart @@ -25,13 +25,15 @@ class ExhibitViewModel with ChangeNotifier { } /// ExhibitModel 리스트로 즐겨찾기 상태 초기화 - /// (이미 존재하는 값은 덮어쓰지 않음) + /// favorite = true면 stale false를 덮어씀, false면 기존 값 유지 void initializeFromExhibits(List exhibits) { for (var exhibit in exhibits) { final id = exhibit.exhibitId; if (id == null) continue; - _favoriteMap.putIfAbsent(id, () => exhibit.favorite); + if (exhibit.favorite || exhibit.active) { + _favoriteMap[id] = true; + } } } diff --git a/lib/shared/widgets/exhibit_list_item.dart b/lib/shared/widgets/exhibit_list_item.dart index c250721..1592b9a 100644 --- a/lib/shared/widgets/exhibit_list_item.dart +++ b/lib/shared/widgets/exhibit_list_item.dart @@ -57,11 +57,14 @@ class ExhibitListItem extends StatelessWidget { : ImageEmptyMediumWidget(width: 100.w, height: 100.w), /// 전시 상태 - item.status != null + item.status?.isNotEmpty == true || + item.exhibitStatus?.isNotEmpty == true ? Positioned( right: 0, bottom: 0, - child: ExhibitStatusBadge(item.status!), + child: ExhibitStatusBadge( + item.status ?? item.exhibitStatus!, + ), ) : const SizedBox.shrink(), @@ -106,14 +109,21 @@ class ExhibitListItem extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ if (showArea && - (item.countryName != null || item.regionName != null)) + (item.countryName?.isNotEmpty == true || + item.regionName?.isNotEmpty == true || + item.country?.isNotEmpty == true || + item.region?.isNotEmpty == true)) ArtTripText.pretendard() .body01Regular() .color(const Color(0xFF7859FF)) .build() .text( !isDomestic - ? (item.countryName ?? item.regionName) ?? '' + ? (item.countryName ?? + item.country ?? + item.regionName ?? + item.region) ?? + '' : item.regionName ?? '', ), if (item.title?.isNotEmpty == true) @@ -126,12 +136,13 @@ class ExhibitListItem extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, spacing: 2.h, children: [ - if (item.hallName?.isNotEmpty == true) + if (item.hallName?.isNotEmpty == true || + item.exhibitHallName?.isNotEmpty == true) ArtTripText.pretendard() .body02Regular() .color(AppColors.textTertiary) .build() - .text(item.hallName!), + .text(item.hallName ?? item.exhibitHallName!), if (item.exhibitPeriod?.isNotEmpty == true) ArtTripText.pretendard() .body02Regular() From b8c1cb8a9bbd7802d4d61525a5ed790f382e2b16 Mon Sep 17 00:00:00 2001 From: Ju Date: Wed, 10 Jun 2026 16:24:54 +0900 Subject: [PATCH 12/17] =?UTF-8?q?fix:=20null=20check=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/features/home/home_viewmodel.dart | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/features/home/home_viewmodel.dart b/lib/features/home/home_viewmodel.dart index 6b7dd9b..21e9236 100644 --- a/lib/features/home/home_viewmodel.dart +++ b/lib/features/home/home_viewmodel.dart @@ -232,7 +232,7 @@ class HomeViewModel with ChangeNotifier { ); } catch (e) { AppUtil.debugLog('getTodayExhibitRecommendations error: $e'); - _todayExhibitRecommendations[_locationType]![_area[_locationType]!] = + _todayExhibitRecommendations[_locationType]?[_area[_locationType]!] = const AsyncState.error(); } notifyListeners(); @@ -337,7 +337,7 @@ class HomeViewModel with ChangeNotifier { exhibitVM.initializeFromExhibits(result); } catch (e) { AppUtil.debugLog('getPersonalizedExhibits error: $e'); - _personalizedExhibits[_locationType]![_area[_locationType]!] = + _personalizedExhibits[_locationType]?[_area[_locationType]!] = const AsyncState.error(); } notifyListeners(); @@ -411,7 +411,7 @@ class HomeViewModel with ChangeNotifier { ); } catch (e) { AppUtil.debugLog('getCurations error: $e'); - _curations[_locationType]![_area[_locationType]!] = + _curations[_locationType]?[_area[_locationType]!] = const AsyncState.error(); } } From 5b964ac892ff85e592d9fe0e9bb1079acf3b2241 Mon Sep 17 00:00:00 2001 From: Ju Date: Fri, 12 Jun 2026 16:49:50 +0900 Subject: [PATCH 13/17] =?UTF-8?q?fix:=20=EB=A6=AC=EB=B7=B0=EC=9E=91?= =?UTF-8?q?=EC=84=B1=20=EC=8B=9C,=20=EC=9D=BC=EC=9E=90=EC=97=90=20?= =?UTF-8?q?=EB=85=84=EC=9B=94=20=EC=9C=84=EC=B9=98=EB=B3=80=EA=B2=BD=20?= =?UTF-8?q?=EB=B0=8F=20null=20exception=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/features/exhibit/views/write_review_page.dart | 8 ++++---- .../widgets/write_review/write_review_header.dart | 10 +++++----- lib/features/home/home_viewmodel.dart | 2 +- lib/routes/app_router.dart | 2 +- lib/shared/widgets/calendar_bottom_sheet.dart | 13 ++++++------- 5 files changed, 17 insertions(+), 18 deletions(-) diff --git a/lib/features/exhibit/views/write_review_page.dart b/lib/features/exhibit/views/write_review_page.dart index d1f0aa0..f2732d4 100644 --- a/lib/features/exhibit/views/write_review_page.dart +++ b/lib/features/exhibit/views/write_review_page.dart @@ -19,7 +19,7 @@ class WriteReviewPage extends StatefulWidget { /// 신규 작성 시 필수, 수정 모드에서는 사용하지 않음 final int exhibitId; - final WriteReviewParams params; + final WriteReviewParams? params; @override State createState() => _WriteReviewPageState(); @@ -31,9 +31,9 @@ class _WriteReviewPageState extends State { super.initState(); final vm = context.read(); vm.reset(notify: false); - if (widget.params.isEditMode) { + if (widget.params?.isEditMode == true) { WidgetsBinding.instance.addPostFrameCallback((_) { - vm.initForEdit(reviewId: widget.params.reviewId!); + vm.initForEdit(reviewId: widget.params!.reviewId!); }); } } @@ -54,7 +54,7 @@ class _WriteReviewPageState extends State { .color(AppColors.textPrimary) .build() .text( - widget.params.isEditMode + widget.params?.isEditMode == true ? context.l10n.editReviewTitle : context.l10n.writeReviewTitle, ), diff --git a/lib/features/exhibit/widgets/write_review/write_review_header.dart b/lib/features/exhibit/widgets/write_review/write_review_header.dart index ed79755..141b9ec 100644 --- a/lib/features/exhibit/widgets/write_review/write_review_header.dart +++ b/lib/features/exhibit/widgets/write_review/write_review_header.dart @@ -10,7 +10,7 @@ import 'package:flutter_screenutil/flutter_screenutil.dart'; class WriteReviewHeader extends StatelessWidget { const WriteReviewHeader({super.key, required this.params}); - final WriteReviewParams params; + final WriteReviewParams? params; @override Widget build(BuildContext context) { @@ -18,9 +18,9 @@ class WriteReviewHeader extends StatelessWidget { padding: EdgeInsets.symmetric(horizontal: 24.w, vertical: 12.h), child: Row( children: [ - params.posterUrl != null + params?.posterUrl != null ? AppCachedImage( - imageUrl: params.posterUrl!, + imageUrl: params!.posterUrl!, width: 50.w, height: 50.w, borderRadius: BorderRadius.circular(4.r), @@ -44,13 +44,13 @@ class WriteReviewHeader extends StatelessWidget { .title02Bold() .color(AppColors.textPrimary) .build() - .text(params.title), + .text(params?.title ?? ''), SizedBox(height: 4.h), ArtTripText.pretendard() .body02Regular() .color(AppColors.textSecondary) .build() - .text(params.hallName), + .text(params?.hallName ?? ''), ], ), ), diff --git a/lib/features/home/home_viewmodel.dart b/lib/features/home/home_viewmodel.dart index 21e9236..2f3287f 100644 --- a/lib/features/home/home_viewmodel.dart +++ b/lib/features/home/home_viewmodel.dart @@ -376,7 +376,7 @@ class HomeViewModel with ChangeNotifier { exhibitVM.initializeFromExhibits(result); } catch (e) { AppUtil.debugLog('getWeeklyExhibitsBySelectedDate error: $e'); - _weeklyExhibitsBySelectedDate[_locationType]![_area[_locationType]]![selectedDateDay] = + _weeklyExhibitsBySelectedDate[_locationType]?[_area[_locationType]]![selectedDateDay] = const AsyncState.error(); } diff --git a/lib/routes/app_router.dart b/lib/routes/app_router.dart index c0503d1..9650e1c 100644 --- a/lib/routes/app_router.dart +++ b/lib/routes/app_router.dart @@ -93,7 +93,7 @@ final appRouter = GoRouter( path: AppRoutes.exhibitWriteReview, pageBuilder: (context, state) { final id = int.parse(state.pathParameters['id']!); - final params = state.extra as WriteReviewParams; + final params = state.extra as WriteReviewParams?; return buildPage( context, state, diff --git a/lib/shared/widgets/calendar_bottom_sheet.dart b/lib/shared/widgets/calendar_bottom_sheet.dart index 811787c..765bd4e 100644 --- a/lib/shared/widgets/calendar_bottom_sheet.dart +++ b/lib/shared/widgets/calendar_bottom_sheet.dart @@ -96,8 +96,8 @@ class _CalendarBottomSheetState extends State { .build() .text( context.l10n.calendarYearMonth( - _focusedDay.year, _focusedDay.month, + _focusedDay.year, ), ), SizedBox(width: 4.w), @@ -281,11 +281,10 @@ Future showCalendarBottomSheet({ context: context, backgroundColor: Colors.transparent, isScrollControlled: true, - builder: - (context) => CalendarBottomSheet( - initialDate: initialDate, - firstDate: firstDate, - lastDate: lastDate, - ), + builder: (context) => CalendarBottomSheet( + initialDate: initialDate, + firstDate: firstDate, + lastDate: lastDate, + ), ); } From 5bba275a7c574f684999489c371d5bdc21d7b553 Mon Sep 17 00:00:00 2001 From: Ju Date: Mon, 15 Jun 2026 22:20:41 +0900 Subject: [PATCH 14/17] =?UTF-8?q?fix:=20=EC=A6=90=EA=B2=A8=EC=B0=BE?= =?UTF-8?q?=EA=B8=B0=20=ED=95=84=ED=84=B0=20=EB=A1=9C=EC=A7=81=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/features/favorite/favorites_page.dart | 60 ++++++++++++++++------- 1 file changed, 41 insertions(+), 19 deletions(-) diff --git a/lib/features/favorite/favorites_page.dart b/lib/features/favorite/favorites_page.dart index 439b2a1..92fba3b 100644 --- a/lib/features/favorite/favorites_page.dart +++ b/lib/features/favorite/favorites_page.dart @@ -55,7 +55,12 @@ class _FavoritesPageState extends State { final homeVM = context.read(); Future.delayed(Duration.zero, () async { - if (mounted) await homeVM.getDomesticRegions(context); + if (mounted) { + _selectedOverseasCountry.value = context.l10n.allItems; + _selectedDomesticArea.value = context.l10n.allItems; + await homeVM.getDomesticRegions(context); + } + if (homeVM.locationType == LocationType.overseas) { if (mounted) await homeVM.getOverseasCountries(context); } @@ -114,8 +119,12 @@ class _FavoritesPageState extends State { cursor: _cursor, size: _size, sortType: _sortType.value.type, - country: _selectedOverseasCountry.value, - region: _selectedDomesticArea.value, + country: _selectedOverseasCountry.value?.isNotEmpty == true + ? _selectedOverseasCountry.value + : null, + region: _selectedDomesticArea.value?.isNotEmpty == true + ? _selectedDomesticArea.value + : null, ); _exhibits.value = result?.favorites; @@ -135,8 +144,12 @@ class _FavoritesPageState extends State { cursor: _cursor, size: _size, sortType: _sortType.value.type, - country: _selectedOverseasCountry.value, - region: _selectedDomesticArea.value, + country: _selectedOverseasCountry.value?.isNotEmpty == true + ? _selectedOverseasCountry.value + : null, + region: _selectedDomesticArea.value?.isNotEmpty == true + ? _selectedDomesticArea.value + : null, ); _exhibits.value = [...?_exhibits.value, ...?result?.favorites]; _hasNext.value = result?.hasNext ?? false; @@ -391,14 +404,18 @@ class _FavoritesPageState extends State { (index) { final item = data[index]; final isSelected = - overseasCountry == item || - (overseasCountry == null && - index == 0); + overseasCountry == item; return GestureDetector( onTap: () { - tempOverseasCountry.value = - item; + if (tempOverseasCountry.value == + item) { + tempOverseasCountry.value = + ''; + } else { + tempOverseasCountry.value = + item; + } isApplyEnabled.value = true; }, child: Container( @@ -495,18 +512,23 @@ class _FavoritesPageState extends State { (index) { final item = tempList[index]; final isSelected = - domesticArea == item.region || - (domesticArea == null && - index == 0); + domesticArea == item.region; return GestureDetector( onTap: () { - if (index == 0) { - tempDomesticArea.value = null; + if (tempDomesticArea.value == + item.region) { + tempDomesticArea.value = ''; } else { - tempDomesticArea.value = - item.region; + if (index == 0) { + tempDomesticArea.value = + context.l10n.allItems; + } else { + tempDomesticArea.value = + item.region; + } } + isApplyEnabled.value = true; }, child: Container( @@ -568,8 +590,8 @@ class _FavoritesPageState extends State { /// 전체 해제 GestureDetector( onTap: () { - tempOverseasCountry.value = null; - tempDomesticArea.value = null; + tempOverseasCountry.value = context.l10n.allItems; + tempDomesticArea.value = context.l10n.allItems; isApplyEnabled.value = true; }, child: ColoredBox( From 51ead67b74e90ff800558c17a12870967a591e7f Mon Sep 17 00:00:00 2001 From: Ju Date: Mon, 15 Jun 2026 23:21:11 +0900 Subject: [PATCH 15/17] =?UTF-8?q?fix:=20=EC=9A=B4=EC=98=81=20=EB=8F=84?= =?UTF-8?q?=EB=A9=94=EC=9D=B8=20=EC=B6=94=EA=B0=80,=20=EB=A6=B4=EB=A6=AC?= =?UTF-8?q?=EC=A6=88=20=EB=B2=84=EC=A0=84=EC=97=85,=20=EC=A0=84=EC=8B=9C?= =?UTF-8?q?=EC=83=81=EC=84=B8=20=ED=8F=B0=ED=8A=B8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/core/app_consts.dart | 1 + lib/core/env.dart | 5 ++- .../exhibit/widgets/exhibit_detail_tab.dart | 8 ++--- .../widgets/exhibit_header_section.dart | 6 ++-- .../exhibit/widgets/exhibit_info_row.dart | 4 +-- .../exhibit/widgets/exhibit_review_tab.dart | 6 ++-- .../exhibit/widgets/exhibit_tab_bar.dart | 30 ++++++++--------- .../exhibit/widgets/review_list_item.dart | 33 +++++++++---------- pubspec.yaml | 2 +- 9 files changed, 49 insertions(+), 46 deletions(-) diff --git a/lib/core/app_consts.dart b/lib/core/app_consts.dart index 1d12c87..02f937c 100644 --- a/lib/core/app_consts.dart +++ b/lib/core/app_consts.dart @@ -1,6 +1,7 @@ class AppConsts { const AppConsts._(); + static const bool isDevServer = true; // true: 개발 서버, false: 운영 서버 static const bool useMock = false; // Force using dummy data static const int mockLoadingDelayMs = 500; // Dummy data loading delay in milliseconds diff --git a/lib/core/env.dart b/lib/core/env.dart index 768f1eb..a6844e4 100644 --- a/lib/core/env.dart +++ b/lib/core/env.dart @@ -1,3 +1,4 @@ +import 'package:arttrip/core/app_consts.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart'; /// 환경 변수 접근 클래스 @@ -9,7 +10,9 @@ class Env { dotenv.env['KAKAO_NATIVE_APP_KEY'] ?? ''; /// API Base URL - static String get apiBaseUrl => dotenv.env['API_BASE_URL'] ?? ''; + static String get apiBaseUrl => AppConsts.isDevServer + ? dotenv.env['API_BASE_URL'] ?? '' + : dotenv.env['REAL_API_BASE_URL'] ?? ''; /// Google iOS Client ID (웹 앱 Client ID와 같은 GCP 프로젝트의 iOS 타입) static String get googleClientId => dotenv.env['GOOGLE_CLIENT_ID'] ?? ''; diff --git a/lib/features/exhibit/widgets/exhibit_detail_tab.dart b/lib/features/exhibit/widgets/exhibit_detail_tab.dart index a0c91c3..f16974d 100644 --- a/lib/features/exhibit/widgets/exhibit_detail_tab.dart +++ b/lib/features/exhibit/widgets/exhibit_detail_tab.dart @@ -47,7 +47,7 @@ class ExhibitDetailModelTabContent extends StatelessWidget { Align( alignment: Alignment.center, child: ArtTripText.pretendard() - .body03Regular() + .body02Regular() .color(AppColors.textSecondary) .build() .text(context.l10n.exhibitAddressNotice), @@ -106,7 +106,7 @@ class ExhibitDetailModelTabContent extends StatelessWidget { ); }, child: ArtTripText.pretendard() - .body02Regular() + .body01Regular() .color(AppColors.textPoint) .build() .text(context.l10n.copy), @@ -125,13 +125,13 @@ class ExhibitDetailModelTabContent extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ ArtTripText.pretendard() - .body02Bold() + .title01Bold() .color(AppColors.textPrimary) .build() .text(context.l10n.exhibitDescription), SizedBox(height: 8.h), ArtTripText.pretendard() - .body02Light() + .title01Light() .color(AppColors.textPrimary) .build() .text(exhibit.description), diff --git a/lib/features/exhibit/widgets/exhibit_header_section.dart b/lib/features/exhibit/widgets/exhibit_header_section.dart index 9f87ba9..087c6e8 100644 --- a/lib/features/exhibit/widgets/exhibit_header_section.dart +++ b/lib/features/exhibit/widgets/exhibit_header_section.dart @@ -40,7 +40,7 @@ class ExhibitHeaderSection extends StatelessWidget { Widget _buildTitle() { return ArtTripText.pretendard() - .title01Bold() + .headline() .color(AppColors.textPrimary) .build() .text(title); @@ -48,7 +48,7 @@ class ExhibitHeaderSection extends StatelessWidget { Widget _buildSubInfo(String text) { return ArtTripText.pretendard() - .body02Regular() + .body01Regular() .color(AppColors.textPrimary) .build() .text(text); @@ -74,7 +74,7 @@ class ExhibitHeaderSection extends StatelessWidget { ), ), child: ArtTripText.pretendard() - .body01Bold() + .title02Bold() .color(AppColors.textWhite) .build() .text(context.l10n.goToHomepage), diff --git a/lib/features/exhibit/widgets/exhibit_info_row.dart b/lib/features/exhibit/widgets/exhibit_info_row.dart index a3bb64b..aa937ab 100644 --- a/lib/features/exhibit/widgets/exhibit_info_row.dart +++ b/lib/features/exhibit/widgets/exhibit_info_row.dart @@ -31,7 +31,7 @@ class ExhibitInfoRow extends StatelessWidget { SizedBox( width: 66.w, child: ArtTripText.pretendard() - .body02Bold() + .body01Bold() .color(AppColors.textPrimary) .build() .text(label), @@ -42,7 +42,7 @@ class ExhibitInfoRow extends StatelessWidget { children: [ Flexible( child: ArtTripText.pretendard() - .body02Regular() + .body01Regular() .color( isEmpty ? AppColors.textTertiary : AppColors.textPrimary, ) diff --git a/lib/features/exhibit/widgets/exhibit_review_tab.dart b/lib/features/exhibit/widgets/exhibit_review_tab.dart index 547a073..858f729 100644 --- a/lib/features/exhibit/widgets/exhibit_review_tab.dart +++ b/lib/features/exhibit/widgets/exhibit_review_tab.dart @@ -69,13 +69,13 @@ class _ExhibitReviewModelTabContentState Row( children: [ ArtTripText.pretendard() - .body02Bold() + .body01Bold() .color(AppColors.textPrimary) .build() .text(context.l10n.exhibitReviewCount), SizedBox(width: 4.w), ArtTripText.pretendard() - .body02Bold() + .body01Bold() .color(AppColors.primary300) .build() .text('($totalCount)'), @@ -105,7 +105,7 @@ class _ExhibitReviewModelTabContentState ), ), child: ArtTripText.pretendard() - .body02Bold() + .body01Bold() .color(const Color(0xFF111111)) .build() .text(context.l10n.writeReview), diff --git a/lib/features/exhibit/widgets/exhibit_tab_bar.dart b/lib/features/exhibit/widgets/exhibit_tab_bar.dart index 61787cb..42222c6 100644 --- a/lib/features/exhibit/widgets/exhibit_tab_bar.dart +++ b/lib/features/exhibit/widgets/exhibit_tab_bar.dart @@ -2,6 +2,7 @@ import 'package:arttrip/core/app_colors.dart'; import 'package:arttrip/core/extensions.dart'; import 'package:arttrip/shared/utils/text/arttrip_text.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; /// 전시 상세 페이지의 탭바 위젯 class ExhibitTabBar extends StatelessWidget { @@ -19,26 +20,25 @@ class ExhibitTabBar extends StatelessWidget { controller: tabController, labelColor: AppColors.primary300, unselectedLabelColor: AppColors.textTertiary, - labelStyle: - ArtTripText.pretendard() - .body01Bold() - .color(AppColors.primary300) - .build() - .style(), - unselectedLabelStyle: - ArtTripText.pretendard() - .body01Bold() - .color(AppColors.textTertiary) - .build() - .style(), + labelStyle: ArtTripText.pretendard() + .title02Bold() + .color(AppColors.primary300) + .build() + .style(), + unselectedLabelStyle: ArtTripText.pretendard() + .title02Bold() + .color(AppColors.textTertiary) + .build() + .style(), indicatorColor: AppColors.primary200, indicatorSize: TabBarIndicatorSize.tab, indicatorWeight: 2, dividerHeight: 0, + labelPadding: EdgeInsets.symmetric(vertical: 5.h), tabs: [ - Tab(text: context.l10n.exhibitDetailTab), - Tab(text: context.l10n.exhibitMapTab), - Tab(text: context.l10n.exhibitReviewTab), + Tab(height: 25.h, text: context.l10n.exhibitDetailTab), + Tab(height: 25.h, text: context.l10n.exhibitMapTab), + Tab(height: 25.h, text: context.l10n.exhibitReviewTab), ], ), ); diff --git a/lib/features/exhibit/widgets/review_list_item.dart b/lib/features/exhibit/widgets/review_list_item.dart index b5bbce8..69244d9 100644 --- a/lib/features/exhibit/widgets/review_list_item.dart +++ b/lib/features/exhibit/widgets/review_list_item.dart @@ -35,14 +35,14 @@ class ReviewListItem extends StatelessWidget { children: [ Expanded( child: ArtTripText.pretendard() - .body02Bold() + .body01Bold() .color(const Color(0xFFA5A5AF)) .build() .text(review.reviewer ?? '익명'), ), SizedBox(width: 10.w), ArtTripText.pretendard() - .body02Light() + .body01Light() .color(const Color(0xFFA5A5AF)) .build() .text(review.visitDate), @@ -52,7 +52,7 @@ class ReviewListItem extends StatelessWidget { Widget _buildContent() { return ArtTripText.pretendard() - .body02Regular() + .body01Regular() .color(const Color(0xFF111111)) .build() .text(review.content); @@ -62,20 +62,19 @@ class ReviewListItem extends StatelessWidget { return SingleChildScrollView( scrollDirection: Axis.horizontal, child: Row( - children: - review.photoUrls - .map( - (url) => Padding( - padding: EdgeInsets.only(right: 8.w), - child: AppCachedImage( - imageUrl: url, - width: 100.w, - height: 100.w, - borderRadius: BorderRadius.circular(8.r), - ), - ), - ) - .toList(), + children: review.photoUrls + .map( + (url) => Padding( + padding: EdgeInsets.only(right: 8.w), + child: AppCachedImage( + imageUrl: url, + width: 100.w, + height: 100.w, + borderRadius: BorderRadius.circular(8.r), + ), + ), + ) + .toList(), ), ); } diff --git a/pubspec.yaml b/pubspec.yaml index 45f6d87..9890fe3 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: arttrip description: "ArtTrip - Global Art Exhibition Booking App" publish_to: "none" -version: 1.0.0+11 +version: 1.0.0+12 environment: sdk: ">=3.8.0 <4.0.0" From ead086541167cc447ed81307b880f7729979bd1f Mon Sep 17 00:00:00 2001 From: Ju Date: Tue, 16 Jun 2026 15:45:49 +0900 Subject: [PATCH 16/17] =?UTF-8?q?fix:=20=EB=A6=B4=EB=A6=AC=EC=A6=88?= =?UTF-8?q?=EB=AA=A8=EB=93=9C=EC=97=90=EC=84=9C=20=EC=9A=B4=EC=98=81?= =?UTF-8?q?=EC=84=9C=EB=B2=84=EB=A5=BC=20=EB=B0=94=EB=9D=BC=EB=B3=B4?= =?UTF-8?q?=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/core/app_consts.dart | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/lib/core/app_consts.dart b/lib/core/app_consts.dart index 02f937c..231111b 100644 --- a/lib/core/app_consts.dart +++ b/lib/core/app_consts.dart @@ -1,7 +1,10 @@ +import 'package:flutter/foundation.dart'; + class AppConsts { const AppConsts._(); - static const bool isDevServer = true; // true: 개발 서버, false: 운영 서버 + static const bool _useDevServer = true; // true: 개발 서버, false: 운영 서버 + static const bool isDevServer = _useDevServer && !kReleaseMode; static const bool useMock = false; // Force using dummy data static const int mockLoadingDelayMs = 500; // Dummy data loading delay in milliseconds From 3433dde8d013e4ecc184fae86f13a4046568d3a1 Mon Sep 17 00:00:00 2001 From: Ju Date: Wed, 17 Jun 2026 12:33:45 +0900 Subject: [PATCH 17/17] =?UTF-8?q?fix:=20=ED=99=88=20=ED=81=90=EB=A0=88?= =?UTF-8?q?=EC=9D=B4=EC=85=98=20null=EA=B0=92=EC=9D=B8=20=EA=B2=BD?= =?UTF-8?q?=EC=9A=B0=20=EB=A1=9C=EB=94=A9=20->=20=EC=98=A4=EB=A5=98=20?= =?UTF-8?q?=ED=99=94=EB=A9=B4=EC=9C=BC=EB=A1=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/features/home/views/curation_view.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/features/home/views/curation_view.dart b/lib/features/home/views/curation_view.dart index 78e4e26..628496f 100644 --- a/lib/features/home/views/curation_view.dart +++ b/lib/features/home/views/curation_view.dart @@ -39,7 +39,7 @@ class _CurationViewState extends State { return Selector>( selector: (_, vm) => vm.curations[vm.locationType]?[vm.area[vm.locationType]!] ?? - const AsyncState.loading(), + const AsyncState.error(), builder: (context, state, _) { return AsyncView( state: state,