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/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/app_consts.dart b/lib/core/app_consts.dart
index 1d12c87..231111b 100644
--- a/lib/core/app_consts.dart
+++ b/lib/core/app_consts.dart
@@ -1,6 +1,10 @@
+import 'package:flutter/foundation.dart';
+
class AppConsts {
const AppConsts._();
+ 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
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/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/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/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/exhibit/data/models/exhibit_model.dart b/lib/features/exhibit/data/models/exhibit_model.dart
index bb3695d..eb7651d 100644
--- a/lib/features/exhibit/data/models/exhibit_model.dart
+++ b/lib/features/exhibit/data/models/exhibit_model.dart
@@ -13,10 +13,18 @@ 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,
+ @Default(false) bool active,
@JsonKey(name: 'isFavorite') @Default(false) bool favorite,
}) = _ExhibitModel;
diff --git a/lib/features/exhibit/viewmodels/exhibit_viewmodel.dart b/lib/features/exhibit/viewmodels/exhibit_viewmodel.dart
index aa02206..0bc5c89 100644
--- a/lib/features/exhibit/viewmodels/exhibit_viewmodel.dart
+++ b/lib/features/exhibit/viewmodels/exhibit_viewmodel.dart
@@ -11,19 +11,29 @@ 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;
}
/// 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/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/exhibit_detail_tab.dart b/lib/features/exhibit/widgets/exhibit_detail_tab.dart
index 96caa67..f16974d 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()
+ .body02Regular()
+ .color(AppColors.textSecondary)
+ .build()
+ .text(context.l10n.exhibitAddressNotice),
+ ),
],
),
);
@@ -103,7 +106,7 @@ class ExhibitDetailModelTabContent extends StatelessWidget {
);
},
child: ArtTripText.pretendard()
- .body02Regular()
+ .body01Regular()
.color(AppColors.textPoint)
.build()
.text(context.l10n.copy),
@@ -122,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_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/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/lib/features/exhibit/widgets/write_review/write_review_header.dart b/lib/features/exhibit/widgets/write_review/write_review_header.dart
index cd0282d..141b9ec 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';
@@ -9,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) {
@@ -17,26 +18,23 @@ 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!,
- 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(
@@ -46,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/favorite/favorites_page.dart b/lib/features/favorite/favorites_page.dart
index 9e5226d..92fba3b 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); // 다음 페이지 존재 여부
@@ -43,12 +44,23 @@ 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);
+ 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);
}
@@ -72,10 +84,32 @@ 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;
@@ -85,14 +119,20 @@ 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?.isNotEmpty == true
+ ? _selectedOverseasCountry.value
+ : null,
+ region: _selectedDomesticArea.value?.isNotEmpty == true
+ ? _selectedDomesticArea.value
+ : null,
);
_exhibits.value = result?.favorites;
_hasNext.value = result?.hasNext ?? false;
_cursor = result?.nextCursor ?? 0;
_isLoading.value = false;
+
+ exhibitVM.initializeFromExhibits(result?.favorites ?? []);
}
Future _loadMoreExhibits() async {
@@ -104,13 +144,19 @@ 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?.isNotEmpty == true
+ ? _selectedOverseasCountry.value
+ : null,
+ region: _selectedDomesticArea.value?.isNotEmpty == true
+ ? _selectedDomesticArea.value
+ : null,
);
_exhibits.value = [...?_exhibits.value, ...?result?.favorites];
_hasNext.value = result?.hasNext ?? false;
_cursor = result?.nextCursor ?? 0;
_loadingMore.value = false;
+
+ exhibitVM.initializeFromExhibits(result?.favorites ?? []);
}
@override
@@ -121,173 +167,175 @@ 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),
- );
- },
+ 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),
+ );
+ },
+ ),
),
- ),
- /// 최신순, 마감순, 필터
- 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,
+ /// 최신순, 마감순, 필터
+ ValueListenableBuilder(
+ valueListenable: _sortType,
+ builder: (context, sortType, child) {
+ return Row(
+ children: [
+ _buildSortType(
+ isSelected: sortType == SortType.latest,
+ sortType: SortType.latest,
+ sortTypeName: context.l10n.sortByLatest,
),
- ),
- _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,
+ Container(
+ width: 1.w,
+ height: 12.h,
+ margin: EdgeInsets.symmetric(horizontal: 8.w),
+ decoration: BoxDecoration(
+ borderRadius: BorderRadius.circular(100),
+ color: AppColors.gray100,
+ ),
),
- ),
- ],
- );
- },
- ),
- ],
- ),
- ),
-
- /// 즐겨찾기 전시 리스트
- 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: Column(
- spacing: 8.h,
- mainAxisAlignment: MainAxisAlignment.center,
- crossAxisAlignment: CrossAxisAlignment.center,
- children: [
- SvgPicture.asset(
- AppAssets.icFavorite,
- width: 96.w,
- height: 96.w,
+ _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,
+ isDomestic: false,
+ showArea: true,
+ forceFavorite: true,
+ );
+ },
+ ),
+ );
+ },
+ );
+ },
+ );
+ },
+ ),
+ ],
+ ),
),
),
);
}
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,
@@ -325,11 +373,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: [
@@ -341,9 +389,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, _) {
@@ -358,13 +404,19 @@ class _FavoritesPageState extends State {
(index) {
final item = data[index];
final isSelected =
- selectedArea == item &&
- !isDomestic;
+ overseasCountry == item;
+
return GestureDetector(
onTap: () {
- tempArea.value = item;
- tempIsDomestic.value = false;
- updateApplyEnabled();
+ if (tempOverseasCountry.value ==
+ item) {
+ tempOverseasCountry.value =
+ '';
+ } else {
+ tempOverseasCountry.value =
+ item;
+ }
+ isApplyEnabled.value = true;
},
child: Container(
padding: EdgeInsets.symmetric(
@@ -428,9 +480,7 @@ class _FavoritesPageState extends State {
ArtTripText.pretendard()
.body01Bold()
.build()
- .text(
- context.l10n.domestic,
- ),
+ .text(context.l10n.domestic),
Selector<
HomeViewModel,
AsyncState>
@@ -462,13 +512,24 @@ class _FavoritesPageState extends State {
(index) {
final item = tempList[index];
final isSelected =
- selectedArea == item.region &&
- isDomestic;
+ domesticArea == item.region;
+
return GestureDetector(
onTap: () {
- tempArea.value = item.region;
- tempIsDomestic.value = true;
- updateApplyEnabled();
+ if (tempDomesticArea.value ==
+ item.region) {
+ tempDomesticArea.value = '';
+ } else {
+ if (index == 0) {
+ tempDomesticArea.value =
+ context.l10n.allItems;
+ } else {
+ tempDomesticArea.value =
+ item.region;
+ }
+ }
+
+ isApplyEnabled.value = true;
},
child: Container(
padding: EdgeInsets.symmetric(
@@ -529,8 +590,9 @@ class _FavoritesPageState extends State {
/// 전체 해제
GestureDetector(
onTap: () {
- tempArea.value = null;
- updateApplyEnabled();
+ tempOverseasCountry.value = context.l10n.allItems;
+ tempDomesticArea.value = context.l10n.allItems;
+ isApplyEnabled.value = true;
},
child: ColoredBox(
color: Colors.transparent,
@@ -560,23 +622,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();
}
diff --git a/lib/features/home/home_viewmodel.dart b/lib/features/home/home_viewmodel.dart
index c610074..2f3287f 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;
@@ -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();
@@ -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();
}
@@ -371,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();
}
@@ -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/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/curation_view.dart b/lib/features/home/views/curation_view.dart
index b482214..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,
@@ -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/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/personalized_exhibits_view.dart b/lib/features/home/views/personalized_exhibits_view.dart
index 4de85c0..627271e 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';
@@ -33,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(
@@ -41,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,
@@ -82,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,
@@ -106,17 +111,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/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/home/widgets/today_exhibit_widget.dart b/lib/features/home/widgets/today_exhibit_widget.dart
index 10d9b24..6920c9b 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(
@@ -139,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/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/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/map/widgets/map_bottom_sheet.dart b/lib/features/map/widgets/map_bottom_sheet.dart
index c2331b5..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 =
@@ -64,7 +67,9 @@ class MapBottomSheet extends StatelessWidget {
// 드래그 핸들 (스크롤 시 고정)
SliverPersistentHeader(
pinned: true,
- delegate: _HandleHeaderDelegate(onTap: _onHandleTap),
+ delegate: _HandleHeaderDelegate(
+ sheetController: sheetController,
+ ),
),
// 접힌 상태: "전시 리스트 확인하기"
@@ -109,6 +114,7 @@ class MapBottomSheet extends StatelessWidget {
),
child: ExhibitListItem(
item: exhibits[index],
+ isDomestic: isDomestic,
),
);
},
@@ -154,23 +160,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 +178,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 +228,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/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/features/my/data/my_repository.dart b/lib/features/my/data/my_repository.dart
index 7f494bc..6db6555 100644
--- a/lib/features/my/data/my_repository.dart
+++ b/lib/features/my/data/my_repository.dart
@@ -35,6 +35,12 @@ abstract class MyRepository {
/// fcm token 등록
Future registerFcmToken(String token);
+
+ /// 푸시 알림 수신 여부 조회
+ Future fetchPushEnabled();
+
+ /// 푸시 알림 수신 여부 변경
+ Future updatePushEnabled(bool enabled);
}
class MyRepositoryImpl implements MyRepository {
@@ -175,4 +181,31 @@ class MyRepositoryImpl implements MyRepository {
AppUtil.debugLog('registerFcmToken failed: $e');
}
}
+
+ @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 {
+ 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..20ee004 100644
--- a/lib/features/my/data/my_repository_mock.dart
+++ b/lib/features/my/data/my_repository_mock.dart
@@ -109,4 +109,16 @@ class MyRepositoryMockImpl implements MyRepository {
Future registerFcmToken(String token) {
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));
+ return true;
+ }
}
diff --git a/lib/features/my/viewmodels/my_viewmodel.dart b/lib/features/my/viewmodels/my_viewmodel.dart
index 42a3226..c7c2389 100644
--- a/lib/features/my/viewmodels/my_viewmodel.dart
+++ b/lib/features/my/viewmodels/my_viewmodel.dart
@@ -167,4 +167,12 @@ 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/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..2e40851 100644
--- a/lib/features/my/views/notification_settings_page.dart
+++ b/lib/features/my/views/notification_settings_page.dart
@@ -1,10 +1,12 @@
import 'package:arttrip/core/app_colors.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 +17,38 @@ class NotificationSettingsPage extends StatefulWidget {
}
class _NotificationSettingsPageState extends State {
- bool _exhibitInfo = false;
- bool _stampIssue = false;
- bool _notice = false;
+ 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;
+
+ final previous = _notice;
+ setState(() {
+ _notice = value;
+ _isUpdating = true;
+ });
+
+ final success = await context.read().updatePushEnabled(value);
+
+ if (!mounted) return;
+ setState(() {
+ if (!success) _notice = previous;
+ _isUpdating = false;
+ });
+ }
@override
Widget build(BuildContext context) {
@@ -42,22 +73,12 @@ 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),
+ value: _notice ?? false,
+ onChanged: (_notice == null || _isUpdating)
+ ? null
+ : _onNoticeChanged,
),
],
),
@@ -77,7 +98,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/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/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/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/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/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/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/l10n/app_ko.arb b/lib/l10n/app_ko.arb
index 21a7714..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": "전체",
@@ -94,7 +95,6 @@
"serviceNotification": "서비스 알림",
"serviceNotificationDesc": "서비스 이용에 필요한 내용을 알려드립니다.",
"notifExhibitInfo": "전시 정보",
- "notifStampIssue": "스탬프 발급",
"notifNotice": "안내 알림",
"badWordDialogTitle": "금칙어가 포함되어있습니다.",
"badWordDialogContent": "부적절한 표현이 있어 등록이 제한되었습니다.\n수정 후 다시 시도해 주세요.",
@@ -185,7 +185,8 @@
"minutesAgo": "{min}분 전",
"hoursAgo": "{hour}시간 전",
"noAlertsTitle": "알림이 없습니다.",
- "noImage": "이미지 준비중입니다",
+ "noImageBig": "이미지 준비중입니다",
+ "noImageMedium": "이미지 준비중",
"maintenanceTitle": "📢 서버 점검 안내",
"maintenanceMessage": "보다 안정적인 서비스 제공을 위해\n현재 서버 점검이 진행 중입니다.",
"termsAgreementDescription": "아트트립에 오신 것을 환영합니다\n이용약관에 동의해주세요",
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/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;
}
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/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/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,
+ ),
);
}
diff --git a/lib/shared/widgets/exhibit_list_item.dart b/lib/shared/widgets/exhibit_list_item.dart
index 1406ff1..1592b9a 100644
--- a/lib/shared/widgets/exhibit_list_item.dart
+++ b/lib/shared/widgets/exhibit_list_item.dart
@@ -7,16 +7,27 @@ 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';
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) {
@@ -38,25 +49,22 @@ 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
+ 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(),
@@ -76,11 +84,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,25 +109,40 @@ 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(item.countryName ?? item.regionName ?? ''),
- ArtTripText.pretendard().body01Bold().build().text(
- item.title ?? '',
- ),
+ .text(
+ !isDomestic
+ ? (item.countryName ??
+ item.country ??
+ item.regionName ??
+ item.region) ??
+ ''
+ : item.regionName ?? '',
+ ),
+ if (item.title?.isNotEmpty == true)
+ ArtTripText.pretendard()
+ .body01Bold()
+ .ellipsis(2)
+ .build()
+ .text(item.title!),
Column(
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()
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..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 위젯
///
@@ -23,6 +25,7 @@ class _MainShellState extends State {
Widget build(BuildContext context) {
return Scaffold(
extendBody: true,
+ extendBodyBehindAppBar: true,
body: widget.navigationShell,
bottomNavigationBar: BottomNavBar(
key: _bottomNavigationKey,
@@ -33,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 0bfeb44..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+1
+version: 1.0.0+12
environment:
sdk: ">=3.8.0 <4.0.0"