diff --git a/assets/svg/ic_tabler_world.svg b/assets/svg/ic_tabler_world.svg
new file mode 100644
index 0000000..87be4df
--- /dev/null
+++ b/assets/svg/ic_tabler_world.svg
@@ -0,0 +1,4 @@
+
diff --git a/lib/core/app_assets.dart b/lib/core/app_assets.dart
index fd8bbe0..370da4f 100644
--- a/lib/core/app_assets.dart
+++ b/lib/core/app_assets.dart
@@ -29,6 +29,7 @@ class AppAssets {
static const String icClose = 'assets/svg/ic_close.svg';
static const String icEmptyProfile = 'assets/svg/ic_empty_profile.svg';
static const String icGroup = 'assets/svg/ic_group.svg';
+ static const String icTablerWorld = 'assets/svg/ic_tabler_world.svg';
// Logo
static const String icLogoWhite = 'assets/svg/ic_logo_white.svg';
diff --git a/lib/core/app_utils.dart b/lib/core/app_utils.dart
index 654a647..6e871f4 100644
--- a/lib/core/app_utils.dart
+++ b/lib/core/app_utils.dart
@@ -1,7 +1,6 @@
-import 'dart:ui';
-
import 'package:arttrip/core/app_consts.dart';
import 'package:flutter/foundation.dart';
+import 'package:flutter/material.dart';
class AppUtil {
const AppUtil._();
@@ -17,7 +16,7 @@ class AppUtil {
return List.generate(7, (index) => sunday.add(Duration(days: index)));
}
- /// 주간 요일
+ /// 일자별 요일
static String weekdayLabel({required DateTime date, required Locale locale}) {
var isKorean = locale.languageCode == 'ko';
var labels = isKorean ? AppConsts.weekDaysKo : AppConsts.weekDaysEn;
@@ -26,6 +25,13 @@ class AppUtil {
return labels[date.weekday % 7];
}
+ /// 일 ~ 월 요일 리스트
+ static List getLocalizedWeekdays(Locale locale) {
+ var isKorean = locale.languageCode == 'ko';
+ var labels = isKorean ? AppConsts.weekDaysKo : AppConsts.weekDaysEn;
+ return labels;
+ }
+
/// '2025-12-25' 포맷 반환
static String formatDateYMD(DateTime date) {
var year = date.year.toString().padLeft(4, '0');
@@ -34,4 +40,10 @@ class AppUtil {
return '$year-$month-$day';
}
+
+ static String getLanguage(BuildContext context) {
+ var locale = Localizations.localeOf(context);
+ var language = locale.languageCode == 'ko' ? 'ko' : 'en';
+ return language;
+ }
}
diff --git a/lib/features/exhibit/data/models/exhibit_model.dart b/lib/features/exhibit/data/models/exhibit_model.dart
index 6a26239..85b1a25 100644
--- a/lib/features/exhibit/data/models/exhibit_model.dart
+++ b/lib/features/exhibit/data/models/exhibit_model.dart
@@ -16,7 +16,7 @@ abstract class ExhibitModel with _$ExhibitModel {
String? hallName,
String? countryName,
String? regionName,
- @Default(false) bool isFavorite, // TODO: 컬럼명 확인 필요
+ @Default(false) bool favorite,
}) = _ExhibitModel;
factory ExhibitModel.fromJson(Map json) =>
diff --git a/lib/features/exhibit/viewmodels/exhibit_viewmodel.dart b/lib/features/exhibit/viewmodels/exhibit_viewmodel.dart
index 3aa7bbd..17903a8 100644
--- a/lib/features/exhibit/viewmodels/exhibit_viewmodel.dart
+++ b/lib/features/exhibit/viewmodels/exhibit_viewmodel.dart
@@ -20,7 +20,7 @@ class ExhibitViewModel with ChangeNotifier {
var id = exhibit.exhibitId;
if (id == null) continue;
- _favoriteMap.putIfAbsent(id, () => exhibit.isFavorite);
+ _favoriteMap.putIfAbsent(id, () => exhibit.favorite);
}
}
diff --git a/lib/features/home/home_page.dart b/lib/features/home/home_page.dart
index f18e8da..67a2569 100644
--- a/lib/features/home/home_page.dart
+++ b/lib/features/home/home_page.dart
@@ -8,6 +8,7 @@ import 'package:arttrip/features/home/views/personalized_exhibits_view.dart';
import 'package:arttrip/features/home/views/regional_exhibits_view.dart';
import 'package:arttrip/features/home/views/today_exhibits_recommendation_view.dart';
import 'package:arttrip/features/home/views/weekly_exhibits_schedule_view.dart';
+import 'package:arttrip/features/home/widgets/date_filter_bottom_sheet.dart';
import 'package:arttrip/shared/utils/text/arttrip_text.dart';
import 'package:arttrip/shared/widgets/alert_badge.dart';
import 'package:flutter/material.dart';
@@ -83,27 +84,33 @@ class _HomePageState extends State with TickerProviderStateMixin {
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
SvgPicture.asset(AppAssets.icLogoBlack, width: 88.w, height: 28.h),
- Row(
- spacing: 20.w,
- children: [
- const AlertBadge(path: '/alerts'),
- GestureDetector(
- onTap: () {},
- child: SvgPicture.asset(
- AppAssets.icCalendar,
- width: 24.w,
- height: 24.w,
- ),
- ),
- GestureDetector(
- onTap: () {},
- child: SvgPicture.asset(
- AppAssets.icSearch,
- width: 24.w,
- height: 24.w,
- ),
- ),
- ],
+ Selector(
+ selector: (_, vm) => vm.isDomestic,
+ builder: (context, isDomestic, _) {
+ return Row(
+ spacing: 20.w,
+ children: [
+ const AlertBadge(path: '/alerts'),
+ if (!isDomestic)
+ GestureDetector(
+ onTap: () => _showDateFilterBottomSheet(),
+ child: SvgPicture.asset(
+ AppAssets.icCalendar,
+ width: 24.w,
+ height: 24.w,
+ ),
+ ),
+ GestureDetector(
+ onTap: () {},
+ child: SvgPicture.asset(
+ AppAssets.icSearch,
+ width: 24.w,
+ height: 24.w,
+ ),
+ ),
+ ],
+ );
+ },
),
],
),
@@ -164,4 +171,35 @@ class _HomePageState extends State with TickerProviderStateMixin {
),
);
}
+
+ void _showDateFilterBottomSheet() {
+ showModalBottomSheet(
+ context: context,
+ useRootNavigator: true,
+ shape: RoundedRectangleBorder(
+ borderRadius: BorderRadiusGeometry.only(
+ topLeft: Radius.circular(16.r),
+ topRight: Radius.circular(16.r),
+ ),
+ ),
+ backgroundColor: AppColors.subLightGray,
+ isScrollControlled: true,
+ builder:
+ (_) => Selector?>(
+ selector: (_, vm) => vm.overseasCountriesCache,
+ builder: (context, overseasCountries, _) {
+ if (overseasCountries == null) {
+ return Container(
+ height: MediaQuery.of(context).size.height / 2,
+ alignment: Alignment.center,
+ child: const CircularProgressIndicator(
+ color: AppColors.primary300,
+ ),
+ );
+ }
+ return DateFilterBottomSheet(overseasCountries);
+ },
+ ),
+ );
+ }
}
diff --git a/lib/features/home/home_repository.dart b/lib/features/home/home_repository.dart
index 1eaacf0..11da4d2 100644
--- a/lib/features/home/home_repository.dart
+++ b/lib/features/home/home_repository.dart
@@ -2,10 +2,11 @@ import 'package:arttrip/core/app_utils.dart';
import 'package:arttrip/core/network/dio_client.dart';
import 'package:arttrip/features/exhibit/data/models/exhibit_model.dart';
import 'package:arttrip/shared/models/base_result_model.dart';
+import 'package:arttrip/shared/models/region_model.dart';
abstract class HomeRepository {
Future?> fetchOverseasCountries();
- Future?> fetchDomesticRegions();
+ Future?> fetchDomesticRegions();
Future?> fetchTodayExhibitRecommendations({
required bool isDomestic,
String? country,
@@ -55,10 +56,11 @@ class HomeRepositoryImpl implements HomeRepository {
}
@override
- Future?> fetchDomesticRegions() async {
+ Future?> fetchDomesticRegions() async {
try {
var response = await _dio.get('/exhibit/domestic');
var model = BaseResultModel.fromJson(response.dataOrNull);
+
if (model.result is! List) {
AppUtil.debugLog(
'fetchDomesticRegions type inconsistency: ${model.result.runtimeType}',
@@ -66,7 +68,9 @@ class HomeRepositoryImpl implements HomeRepository {
return null;
}
- return model.result.map((e) => e.toString()).toList();
+ return model.result
+ .map((e) => RegionModel.fromJson(e))
+ .toList();
} catch (e) {
AppUtil.debugLog('fetchDomesticRegions: $e');
}
diff --git a/lib/features/home/home_repository_hybrid.dart b/lib/features/home/home_repository_hybrid.dart
index b2d6610..3fbd3e5 100644
--- a/lib/features/home/home_repository_hybrid.dart
+++ b/lib/features/home/home_repository_hybrid.dart
@@ -1,6 +1,7 @@
import 'package:arttrip/core/app_consts.dart';
import 'package:arttrip/features/exhibit/data/models/exhibit_model.dart';
import 'package:arttrip/features/home/home_repository.dart';
+import 'package:arttrip/shared/models/region_model.dart';
class HomeRepositoryHybrid implements HomeRepository {
HomeRepositoryHybrid({required this.mock, required this.api});
@@ -17,7 +18,7 @@ class HomeRepositoryHybrid implements HomeRepository {
}
@override
- Future?> fetchDomesticRegions() {
+ Future?> fetchDomesticRegions() {
if (AppConsts.useMock) {
return mock.fetchDomesticRegions();
}
diff --git a/lib/features/home/home_repository_mock.dart b/lib/features/home/home_repository_mock.dart
index 1e33440..a2724fd 100644
--- a/lib/features/home/home_repository_mock.dart
+++ b/lib/features/home/home_repository_mock.dart
@@ -2,6 +2,7 @@ import 'package:arttrip/core/app_consts.dart';
import 'package:arttrip/core/app_urls.dart';
import 'package:arttrip/features/exhibit/data/models/exhibit_model.dart';
import 'package:arttrip/features/home/home_repository.dart';
+import 'package:arttrip/shared/models/region_model.dart';
class HomeRepositoryMockImpl implements HomeRepository {
@override
@@ -13,11 +14,18 @@ class HomeRepositoryMockImpl implements HomeRepository {
}
@override
- Future?> fetchDomesticRegions() async {
+ Future?> fetchDomesticRegions() async {
await Future.delayed(
const Duration(milliseconds: AppConsts.mockLoadingDelayMs),
);
- return ['서울', '경기', '충청', '강원', '전라', '경상', '제주'];
+ return [
+ RegionModel(region: '서울', imageUrl: AppUrls.posterUrlMock),
+ RegionModel(region: '경기', imageUrl: AppUrls.posterUrlMock),
+ RegionModel(region: '전라', imageUrl: AppUrls.posterUrlMock),
+ RegionModel(region: '제주', imageUrl: AppUrls.posterUrlMock),
+ RegionModel(region: '경상', imageUrl: AppUrls.posterUrlMock),
+ RegionModel(region: '강원', imageUrl: AppUrls.posterUrlMock),
+ ];
}
@override
diff --git a/lib/features/home/home_viewmodel.dart b/lib/features/home/home_viewmodel.dart
index 7ab1c6b..206cd0a 100644
--- a/lib/features/home/home_viewmodel.dart
+++ b/lib/features/home/home_viewmodel.dart
@@ -6,6 +6,7 @@ import 'package:arttrip/core/extensions.dart';
import 'package:arttrip/features/exhibit/data/models/exhibit_model.dart';
import 'package:arttrip/features/exhibit/viewmodels/exhibit_viewmodel.dart';
import 'package:arttrip/features/home/home_repository.dart';
+import 'package:arttrip/shared/models/region_model.dart';
import 'package:arttrip/shared/widgets/async_view.dart';
import 'package:flutter/material.dart';
@@ -15,7 +16,7 @@ class HomeViewModel with ChangeNotifier {
final HomeRepository homeRepository;
AsyncState> overseasCountries = const AsyncState.loading();
- AsyncState> domesticRegions = const AsyncState.loading();
+ AsyncState> domesticRegions = const AsyncState.loading();
AsyncState> todayExhibitRecommendations =
const AsyncState.loading();
Map>> genres = {
@@ -42,7 +43,7 @@ class HomeViewModel with ChangeNotifier {
AsyncState> weeklyCalendar = const AsyncState.loading();
List? _overseasCountriesCache;
- List? _domesticRegionsCache;
+ List? _domesticRegionsCache;
final Map> _todayExhibitRecommendationsCache = {};
final Map> _personalizedExhibitsCache = {};
List? _weeklyCalendarCache;
@@ -64,7 +65,8 @@ class HomeViewModel with ChangeNotifier {
String get locationType =>
_isDomestic ? LocationType.domestic.name : LocationType.overseas.name;
- List? get domesticRegionsCache => _domesticRegionsCache;
+ List? get overseasCountriesCache => _overseasCountriesCache;
+ List? get domesticRegionsCache => _domesticRegionsCache;
set isDomestic(bool value) {
_isDomestic = value;
@@ -176,7 +178,7 @@ class HomeViewModel with ChangeNotifier {
var key = _isDomestic ? LocationType.domestic.name : _selectedLocation;
if (_todayExhibitRecommendationsCache[key] != null) {
todayExhibitRecommendations = AsyncState.success(
- _todayExhibitRecommendationsCache[_selectedLocation]!,
+ _todayExhibitRecommendationsCache[key]!,
);
notifyListeners();
return;
diff --git a/lib/features/home/regional_exhibits_page.dart b/lib/features/home/regional_exhibits_page.dart
index d22f96f..09b1154 100644
--- a/lib/features/home/regional_exhibits_page.dart
+++ b/lib/features/home/regional_exhibits_page.dart
@@ -2,6 +2,7 @@ import 'package:arttrip/core/app_assets.dart';
import 'package:arttrip/core/app_colors.dart';
import 'package:arttrip/core/extensions.dart';
import 'package:arttrip/features/home/home_viewmodel.dart';
+import 'package:arttrip/shared/models/region_model.dart';
import 'package:arttrip/shared/utils/text/arttrip_text.dart';
import 'package:arttrip/shared/widgets/common_appbar.dart';
import 'package:flutter/material.dart';
@@ -133,7 +134,7 @@ class _RegionalExhibitsPageState extends State {
ArtTripText.pretendard().body01Bold().build().text(
context.l10n.domestic,
),
- Selector>(
+ Selector>(
selector: (_, vm) => vm.domesticRegionsCache!,
builder: (context, domesticRegionsCache, _) {
return Wrap(
@@ -143,10 +144,10 @@ class _RegionalExhibitsPageState extends State {
index,
) {
var item = domesticRegionsCache[index];
- var isSelected = selectedRegion == item;
+ var isSelected = selectedRegion == item.region;
return GestureDetector(
onTap: () {
- _regionName.value = item;
+ _regionName.value = item.region;
Navigator.pop(context);
},
child: Container(
@@ -167,11 +168,11 @@ class _RegionalExhibitsPageState extends State {
.body01Bold()
.color(AppColors.textWhite)
.build()
- .text(item)
+ .text(item.region)
: ArtTripText.pretendard()
.body01Light()
.build()
- .text(item),
+ .text(item.region),
),
);
}),
diff --git a/lib/features/home/views/regional_exhibits_view.dart b/lib/features/home/views/regional_exhibits_view.dart
index 72477da..b696702 100644
--- a/lib/features/home/views/regional_exhibits_view.dart
+++ b/lib/features/home/views/regional_exhibits_view.dart
@@ -2,7 +2,9 @@ import 'package:arttrip/core/app_consts.dart';
import 'package:arttrip/core/extensions.dart';
import 'package:arttrip/features/home/home_viewmodel.dart';
import 'package:arttrip/routes/routes.dart';
+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/shimmer_skeleton_item.dart';
import 'package:flutter/material.dart';
@@ -23,7 +25,7 @@ class _RegionalExhibitsViewState extends State {
return SliverPadding(
padding: EdgeInsetsGeometry.only(top: 32.h),
sliver: SliverToBoxAdapter(
- child: Selector>>(
+ child: Selector>>(
selector: (_, vm) => vm.domesticRegions,
builder: (context, state, _) {
return AsyncView(
@@ -55,22 +57,35 @@ class _RegionalExhibitsViewState extends State {
itemBuilder: (context, index) {
var item = data[index];
return GestureDetector(
- onTap: () => Routes.push(context, '/home/$item'),
+ onTap:
+ () => Routes.push(
+ context,
+ '/home/${item.region}',
+ ),
child: ColoredBox(
color: Colors.transparent,
child: Column(
mainAxisAlignment:
MainAxisAlignment.spaceBetween,
children: [
- CircleAvatar(
- radius: 32.w,
- backgroundColor: Colors.black,
+ ClipRRect(
+ borderRadius: BorderRadiusGeometry.circular(
+ 100,
+ ),
+ child:
+ item.imageUrl.isNotEmpty
+ ? AppCachedImage(
+ imageUrl: item.imageUrl,
+ width: 64.w,
+ height: 64.w,
+ )
+ : const SizedBox.shrink(),
),
ArtTripText.pretendard()
.body02Bold()
.textAlign(TextAlign.center)
.build()
- .text(item),
+ .text(item.region),
],
),
),
diff --git a/lib/features/home/views/today_exhibits_recommendation_view.dart b/lib/features/home/views/today_exhibits_recommendation_view.dart
index 55afd05..4a6d4a1 100644
--- a/lib/features/home/views/today_exhibits_recommendation_view.dart
+++ b/lib/features/home/views/today_exhibits_recommendation_view.dart
@@ -58,7 +58,9 @@ class _TodayExhibitsRecommendationViewState
location:
(selectedLocation == context.l10n.allItems &&
!widget.isDomestic)
- ? item.countryName ?? item.regionName
+ ? item.countryName
+ : widget.isDomestic
+ ? item.regionName
: null,
onTap:
() => Routes.push(
diff --git a/lib/features/home/widgets/date_filter_bottom_sheet.dart b/lib/features/home/widgets/date_filter_bottom_sheet.dart
new file mode 100644
index 0000000..ae058b4
--- /dev/null
+++ b/lib/features/home/widgets/date_filter_bottom_sheet.dart
@@ -0,0 +1,343 @@
+import 'package:arttrip/core/app_assets.dart';
+import 'package:arttrip/core/app_colors.dart';
+import 'package:arttrip/core/app_utils.dart';
+import 'package:arttrip/core/extensions.dart';
+import 'package:arttrip/features/home/widgets/vertical_range_calendar.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';
+import 'package:intl/intl.dart';
+
+class DateFilterBottomSheet extends StatefulWidget {
+ const DateFilterBottomSheet(this.overseasCountries, {super.key});
+
+ final List overseasCountries;
+
+ @override
+ State createState() => _DateFilterBottomSheetState();
+}
+
+class _DateFilterBottomSheetState extends State {
+ final ValueNotifier _isApplyEnabled = ValueNotifier(false);
+ final ValueNotifier _isCountryCardOpen = ValueNotifier(null);
+ final ValueNotifier _selectedCountry = ValueNotifier('');
+ final ValueNotifier _rangeStart = ValueNotifier(null);
+ final ValueNotifier _rangeEnd = ValueNotifier(null);
+
+ void updateApplyEnabled() {
+ _isApplyEnabled.value =
+ _selectedCountry.value.isNotEmpty &&
+ _rangeStart.value != null &&
+ _rangeEnd.value != null;
+ }
+
+ void updateRanges(DateTime? rangeStart, DateTime? rangeEnd) {
+ _rangeStart.value = rangeStart;
+ _rangeEnd.value = rangeEnd;
+ updateApplyEnabled();
+ }
+
+ String formatSelectedStartDate(DateTime? date) {
+ if (date == null) return '';
+ var language = AppUtil.getLanguage(context);
+ var monthDay = DateFormat('M.dd', language).format(date);
+ var weekday = DateFormat('E', language).format(date);
+
+ return '$monthDay ($weekday)';
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return SizedBox(
+ width: double.infinity,
+ height: MediaQuery.of(context).size.height - 108.h,
+ child: ValueListenableBuilder(
+ valueListenable: _isCountryCardOpen,
+ builder: (context, isCountryCardOpen, child) {
+ return Column(
+ children: [
+ Container(
+ width: 32.w,
+ height: 4.h,
+ margin: EdgeInsets.only(top: 10.h),
+ decoration: BoxDecoration(
+ color: AppColors.gray100,
+ borderRadius: BorderRadius.circular(10.r),
+ ),
+ ),
+ SizedBox(height: 16.h),
+ ArtTripText.pretendard().title02Bold().build().text(
+ context.l10n.selectCountryAndDate,
+ ),
+ SizedBox(height: 24.h),
+
+ /// 국가
+ ValueListenableBuilder(
+ valueListenable: _selectedCountry,
+ builder: (context, selectedCountry, child) {
+ return GestureDetector(
+ onTap: () {
+ _isCountryCardOpen.value = true;
+ },
+ child: _buildCard(
+ Column(
+ children: [
+ Padding(
+ padding: EdgeInsets.symmetric(
+ vertical: 16.h,
+ horizontal: 20.w,
+ ),
+ child: Row(
+ spacing: 12.w,
+ children: [
+ SvgPicture.asset(
+ AppAssets.icTablerWorld,
+ width: 20.w,
+ height: 20.w,
+ ),
+ ArtTripText.pretendard()
+ .font(14)
+ .fontWeight(FontWeight.bold)
+ .build()
+ .text(context.l10n.country),
+ if (selectedCountry.isNotEmpty)
+ Padding(
+ padding: EdgeInsets.only(left: 4.w),
+ child: ArtTripText.pretendard()
+ .body01Bold()
+ .color(AppColors.textPoint)
+ .build()
+ .text(selectedCountry),
+ ),
+ ],
+ ),
+ ),
+ if (isCountryCardOpen == true) _buildCountryCard(),
+ ],
+ ),
+ ),
+ );
+ },
+ ),
+ SizedBox(height: 12.h),
+
+ /// 날짜
+ ValueListenableBuilder(
+ valueListenable: _rangeEnd,
+ builder: (context, rangeEnd, child) {
+ return ValueListenableBuilder(
+ valueListenable: _rangeStart,
+ builder: (context, rangeStart, child) {
+ return GestureDetector(
+ onTap: () {
+ _isCountryCardOpen.value = false;
+ },
+ child: _buildCard(
+ Column(
+ children: [
+ Padding(
+ padding: EdgeInsets.symmetric(
+ vertical: 16.h,
+ horizontal: 20.w,
+ ),
+ child: Row(
+ spacing: 12.w,
+ children: [
+ SvgPicture.asset(
+ AppAssets.icCalendar,
+ width: 20.w,
+ height: 20.w,
+ ),
+ ArtTripText.pretendard()
+ .font(14)
+ .fontWeight(FontWeight.bold)
+ .build()
+ .text(context.l10n.date),
+ if (rangeStart != null || rangeEnd != null)
+ ArtTripText.pretendard()
+ .body01Bold()
+ .color(AppColors.textPoint)
+ .build()
+ .text(
+ '${formatSelectedStartDate(rangeStart)} - ${formatSelectedStartDate(rangeEnd)}',
+ ),
+ ],
+ ),
+ ),
+ if (isCountryCardOpen == false)
+ _buildCalendarCard(),
+ ],
+ ),
+ ),
+ );
+ },
+ );
+ },
+ ),
+ const Expanded(child: SizedBox.shrink()),
+ ValueListenableBuilder(
+ valueListenable: _isApplyEnabled,
+ builder: (context, isApplyEnabled, child) {
+ return Container(
+ width: double.infinity,
+ padding: EdgeInsets.only(
+ left: 24.w,
+ right: 24.w,
+ bottom: 44.h,
+ ),
+ child: ElevatedButton(
+ onPressed: isApplyEnabled ? () {} : null,
+ style: ElevatedButton.styleFrom(
+ backgroundColor: AppColors.primary300,
+ padding: EdgeInsets.symmetric(vertical: 17.h),
+ elevation: 0,
+ shadowColor: Colors.transparent,
+ disabledBackgroundColor: AppColors.gray100,
+ shape: RoundedRectangleBorder(
+ borderRadius: BorderRadiusGeometry.circular(12.r),
+ ),
+ ),
+ child: ArtTripText.pretendard()
+ .title02Bold()
+ .color(
+ isApplyEnabled
+ ? AppColors.textWhite
+ : AppColors.textTertiary,
+ )
+ .build()
+ .text(context.l10n.apply),
+ ),
+ );
+ },
+ ),
+ ],
+ );
+ },
+ ),
+ );
+ }
+
+ Container _buildCard(Widget child) {
+ return Container(
+ width: double.infinity,
+ margin: EdgeInsets.symmetric(horizontal: 24.w),
+ decoration: BoxDecoration(
+ color: AppColors.gray0,
+ borderRadius: BorderRadius.circular(8.r),
+ boxShadow: [
+ BoxShadow(
+ offset: const Offset(0, 2),
+ blurRadius: 4,
+ color: AppColors.gray900.withValues(alpha: 0.04),
+ ),
+ ],
+ ),
+ child: child,
+ );
+ }
+
+ Column _buildCountryCard() {
+ return Column(
+ children: [
+ Container(
+ width: double.infinity,
+ height: 1.w,
+ margin: EdgeInsets.symmetric(horizontal: 12.w),
+ decoration: const BoxDecoration(color: AppColors.gray50),
+ ),
+ ValueListenableBuilder(
+ valueListenable: _selectedCountry,
+ builder: (context, selectedCountry, child) {
+ return Padding(
+ padding: EdgeInsets.all(16.w),
+ child: Wrap(
+ spacing: 12.w,
+ runSpacing: 12.h,
+ children: List.generate(widget.overseasCountries.length, (
+ index,
+ ) {
+ var item = widget.overseasCountries[index];
+ var isSelected = selectedCountry == item;
+ return GestureDetector(
+ onTap: () {
+ _selectedCountry.value = item;
+ updateApplyEnabled();
+ },
+ child: Container(
+ padding: EdgeInsets.symmetric(
+ vertical: 8.h,
+ horizontal: 20.w,
+ ),
+ decoration: BoxDecoration(
+ color:
+ isSelected ? AppColors.primary300 : AppColors.gray0,
+ border: Border.all(
+ color:
+ isSelected
+ ? Colors.transparent
+ : AppColors.gray100,
+ ),
+ borderRadius: BorderRadius.circular(100),
+ ),
+ child:
+ isSelected
+ ? ArtTripText.pretendard()
+ .body01Bold()
+ .color(AppColors.textWhite)
+ .build()
+ .text(item)
+ : ArtTripText.pretendard()
+ .body01Light()
+ .build()
+ .text(item),
+ ),
+ );
+ }),
+ ),
+ );
+ },
+ ),
+ ],
+ );
+ }
+
+ SizedBox _buildCalendarCard() {
+ var weekdays = AppUtil.getLocalizedWeekdays(
+ Localizations.localeOf(context),
+ );
+ return SizedBox(
+ height: 442.h - 52.h,
+ child: Column(
+ children: [
+ /// weekdays
+ Padding(
+ padding: EdgeInsets.symmetric(horizontal: 25.w),
+ child: Row(
+ mainAxisAlignment: MainAxisAlignment.spaceBetween,
+ children: List.generate(weekdays.length, (index) {
+ var item = weekdays[index];
+ return Padding(
+ padding: EdgeInsets.only(top: 4.h, bottom: 8.h),
+ child: ArtTripText.pretendard()
+ .body01Regular()
+ .color(AppColors.textTertiary)
+ .build()
+ .text(item),
+ );
+ }),
+ ),
+ ),
+ Divider(color: AppColors.gray100, height: 1.h),
+
+ /// calendar
+ VerticalRangeCalendar(
+ rangeStart: _rangeStart.value,
+ rangeEnd: _rangeEnd.value,
+ updateRanges: updateRanges,
+ ),
+ ],
+ ),
+ );
+ }
+}
diff --git a/lib/features/home/widgets/vertical_range_calendar.dart b/lib/features/home/widgets/vertical_range_calendar.dart
new file mode 100644
index 0000000..f22ccbc
--- /dev/null
+++ b/lib/features/home/widgets/vertical_range_calendar.dart
@@ -0,0 +1,315 @@
+import 'package:arttrip/core/app_colors.dart';
+import 'package:arttrip/shared/utils/text/arttrip_text.dart';
+import 'package:arttrip/shared/widgets/init_widget.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter_screenutil/flutter_screenutil.dart';
+
+class VerticalRangeCalendar extends StatefulWidget {
+ const VerticalRangeCalendar({
+ super.key,
+ required this.rangeStart,
+ required this.rangeEnd,
+ required this.updateRanges,
+ });
+
+ final DateTime? rangeStart;
+ final DateTime? rangeEnd;
+ final Function(DateTime? rangeStart, DateTime? rangeEnd) updateRanges;
+
+ @override
+ State createState() => _VerticalRangeCalendarState();
+}
+
+class _VerticalRangeCalendarState extends State {
+ late PageController _pageController;
+ final ValueNotifier _focusedDay = ValueNotifier(DateTime.now());
+ DateTime? _rangeStart;
+ DateTime? _rangeEnd;
+ final DateTime _today = DateTime.now();
+ final DateTime _startMonth = DateTime(2020, 1);
+ final DateTime _endMonth = DateTime(2050, 12);
+ late int _totalMonths;
+
+ @override
+ void initState() {
+ super.initState();
+ _pageController = PageController(
+ viewportFraction: 0.8,
+ initialPage: _initialPageFromToday(),
+ );
+ _rangeStart = widget.rangeStart ?? _today;
+ _rangeEnd = widget.rangeEnd;
+
+ _totalMonths =
+ (_endMonth.year - _startMonth.year) * 12 +
+ (_endMonth.month - _startMonth.month) +
+ 1;
+ }
+
+ @override
+ void dispose() {
+ _pageController.dispose();
+ super.dispose();
+ }
+
+ int _initialPageFromToday() {
+ return (_today.year - _startMonth.year) * 12 +
+ (_today.month - _startMonth.month);
+ }
+
+ bool _isSameDay(DateTime a, DateTime b) =>
+ a.year == b.year && a.month == b.month && a.day == b.day;
+
+ bool _isWithinRange(DateTime day) {
+ if (_rangeStart == null || _rangeEnd == null) return false;
+ return day.isAfter(_rangeStart!) && day.isBefore(_rangeEnd!);
+ }
+
+ DateTime _dateFromIndex(int index) {
+ return DateTime(_startMonth.year, _startMonth.month + index, 1);
+ }
+
+ List _daysInMonth(DateTime date) {
+ var firstDay = DateTime(date.year, date.month, 1);
+ var lastDay = DateTime(date.year, date.month + 1, 0);
+
+ var leadingEmptyCount = firstDay.weekday % 7;
+
+ return [
+ // 1일 전 빈칸
+ ...List.generate(leadingEmptyCount, (_) => null),
+
+ // 실제 날짜
+ ...List.generate(
+ lastDay.day,
+ (i) => DateTime(date.year, date.month, i + 1),
+ ),
+ ];
+ }
+
+ bool _isToday(DateTime day) {
+ return day.year == _today.year &&
+ day.month == _today.month &&
+ day.day == _today.day;
+ }
+
+ bool _isTodaySelected() {
+ var start = _rangeStart;
+ var end = _rangeEnd;
+
+ if (start != null && _isToday(start)) return true;
+ if (end != null && _isToday(end)) return true;
+
+ return false;
+ }
+
+ void _onDayTap(DateTime day) {
+ if (_rangeStart == null || _rangeEnd != null) {
+ _rangeStart = day;
+ _rangeEnd = null;
+ } else {
+ if (day.isBefore(_rangeStart!)) {
+ _rangeEnd = _rangeStart;
+ _rangeStart = day;
+ } else {
+ _rangeEnd = day;
+ }
+ }
+ _focusedDay.value = day;
+ }
+
+ bool _isSpecialCalendar(int year, int month) {
+ // 해당 달 1일
+ var firstDay = DateTime(year, month, 1);
+ // 해당 달 마지막 날
+ var lastDay = DateTime(year, month + 1, 0).day;
+
+ if (firstDay.weekday == DateTime.friday && lastDay == 31) {
+ return true;
+ } else if (firstDay.weekday == DateTime.saturday &&
+ (lastDay == 30 || lastDay == 31)) {
+ return true;
+ }
+
+ return false;
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return InitWidget(
+ init: () => widget.updateRanges(_rangeStart, _rangeEnd),
+ child: Expanded(
+ child: PageView.builder(
+ controller: _pageController,
+ padEnds: false,
+ scrollDirection: Axis.vertical,
+ itemCount: _totalMonths,
+ itemBuilder: (context, index) {
+ var date = _dateFromIndex(index);
+ var days = _daysInMonth(date);
+
+ return ValueListenableBuilder(
+ valueListenable: _focusedDay,
+ builder: (_, __, ___) {
+ return Column(
+ children: [
+ // header
+ Padding(
+ padding: EdgeInsets.only(top: 16.h),
+ child: ArtTripText.pretendard()
+ .title02Bold()
+ .build()
+ .text('${date.year}. ${date.month}'),
+ ),
+
+ GridView.count(
+ physics: const NeverScrollableScrollPhysics(),
+ padding: const EdgeInsets.all(0),
+ shrinkWrap: true,
+ crossAxisCount: 7,
+ mainAxisSpacing: 10.h,
+ childAspectRatio:
+ (28 + 16).w /
+ (24 +
+ 12 +
+ (_isSpecialCalendar(date.year, date.month)
+ ? -4.5
+ : 0))
+ .h,
+ children:
+ days.map((day) {
+ if (day == null) {
+ return const SizedBox.shrink();
+ }
+ var isStart =
+ _rangeStart != null &&
+ _isSameDay(day, _rangeStart!);
+ var isEnd =
+ _rangeEnd != null &&
+ _isSameDay(day, _rangeEnd!);
+ var isMiddle = _isWithinRange(day);
+ var isRangeSelected =
+ _rangeStart != null && _rangeEnd != null;
+ var isPastDate = day.isBefore(
+ DateTime(_today.year, _today.month, _today.day),
+ );
+
+ Widget? child;
+
+ /// 선택 날짜 사이
+ if (isMiddle) {
+ child = Container(
+ height: 28.w,
+ margin: EdgeInsets.symmetric(vertical: 6.h),
+ decoration: const BoxDecoration(
+ color: AppColors.primary100,
+ ),
+ alignment: Alignment.center,
+ child: ArtTripText.pretendard()
+ .body01Bold()
+ .build()
+ .text('${day.day}'),
+ );
+ } else if (isStart || isEnd) {
+ /// 선택 날짜 시작과 끝
+ child = Stack(
+ children: [
+ if (isRangeSelected &&
+ (_rangeStart != _rangeEnd))
+ Positioned(
+ top: 0,
+ bottom: 0,
+ left: isRangeSelected && isEnd ? 0 : null,
+ right:
+ isRangeSelected && isStart ? 0 : null,
+ child: Container(
+ width: (28 + 16).w / 2,
+ height: 28.w,
+ margin: EdgeInsets.symmetric(
+ vertical: 6.h,
+ ),
+ alignment: Alignment.center,
+ color: AppColors.primary100,
+ ),
+ ),
+ Positioned(
+ top: 0,
+ left: 0,
+ right: 0,
+ bottom: 0,
+ child: Container(
+ width: 28.w,
+ height: 28.w,
+ decoration: const BoxDecoration(
+ color: AppColors.primary300,
+ shape: BoxShape.circle,
+ ),
+ margin: EdgeInsets.symmetric(
+ vertical: 4.h,
+ ),
+ alignment: Alignment.center,
+ child: ArtTripText.pretendard()
+ .body01Bold()
+ .color(AppColors.textWhite)
+ .build()
+ .text('${day.day}'),
+ ),
+ ),
+ ],
+ );
+ } else if (_isToday(day) && !_isTodaySelected()) {
+ child = Container(
+ width: 28.w,
+ height: 28.w,
+ decoration: BoxDecoration(
+ color: AppColors.subLightGray,
+ shape: BoxShape.circle,
+ border: Border.all(color: AppColors.gray100),
+ ),
+ alignment: Alignment.center,
+ child: ArtTripText.pretendard()
+ .body01Bold()
+ .color(AppColors.textSecondary)
+ .build()
+ .text('${day.day}'),
+ );
+ }
+ return GestureDetector(
+ onTap:
+ isPastDate
+ ? null
+ : () {
+ _onDayTap(day);
+ widget.updateRanges(
+ _rangeStart,
+ _rangeEnd,
+ );
+ },
+ child: Container(
+ alignment: Alignment.center,
+ color: Colors.transparent,
+ child:
+ child ??
+ ArtTripText.pretendard()
+ .body01Bold()
+ .color(
+ isPastDate
+ ? AppColors.textTertiary
+ : AppColors.textPrimary,
+ )
+ .build()
+ .text('${day.day}'),
+ ),
+ );
+ }).toList(),
+ ),
+ ],
+ );
+ },
+ );
+ },
+ ),
+ ),
+ );
+ }
+}
diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb
index a487856..18c10c2 100644
--- a/lib/l10n/app_ko.arb
+++ b/lib/l10n/app_ko.arb
@@ -92,5 +92,9 @@
"deleteAccountTitle": "회원 탈퇴 안내",
"deleteAccountContent": "아트트립 탈퇴시 회원님의 사용정보\n(전시 즐겨찾기 목록, 리뷰, 스탬프 등)는\n모두 삭제 됩니다.",
"deleteAccountQuestion": "탈퇴하시겠습니까?",
- "deleteAccountButton": "탈퇴하기"
-}
+ "deleteAccountButton": "탈퇴하기",
+ "selectCountryAndDate": "국가 및 날짜 선택",
+ "country": "국가",
+ "date": "날짜",
+ "apply": "적용하기"
+}
\ No newline at end of file
diff --git a/lib/shared/models/region_model.dart b/lib/shared/models/region_model.dart
new file mode 100644
index 0000000..5755f77
--- /dev/null
+++ b/lib/shared/models/region_model.dart
@@ -0,0 +1,17 @@
+import 'package:freezed_annotation/freezed_annotation.dart';
+
+part 'region_model.freezed.dart';
+part 'region_model.g.dart';
+
+@freezed
+abstract class RegionModel with _$RegionModel {
+ const RegionModel._();
+
+ factory RegionModel({
+ @Default('') String region,
+ @Default('') String imageUrl,
+ }) = _RegionModel;
+
+ factory RegionModel.fromJson(Map json) =>
+ _$RegionModelFromJson(json);
+}
diff --git a/lib/shared/utils/text/arttrip_text.dart b/lib/shared/utils/text/arttrip_text.dart
index b99242f..a6f3cdd 100644
--- a/lib/shared/utils/text/arttrip_text.dart
+++ b/lib/shared/utils/text/arttrip_text.dart
@@ -37,6 +37,9 @@ abstract class ScaleTypeBuilder {
StyleBuilder body02Regular();
StyleBuilder body02Light();
StyleBuilder body03Regular();
+
+ // custom
+ StyleBuilder font(double fontSize);
}
abstract class StyleBuilder {
diff --git a/lib/shared/utils/text/scale_type_builder_impl.dart b/lib/shared/utils/text/scale_type_builder_impl.dart
index ed1c2e4..c38f00b 100644
--- a/lib/shared/utils/text/scale_type_builder_impl.dart
+++ b/lib/shared/utils/text/scale_type_builder_impl.dart
@@ -114,4 +114,13 @@ class ScaleTypeBuilderImpl extends ScaleTypeBuilder {
_style.letterSpacing = 0;
return StyleBuilderImpl(_style);
}
+
+ @override
+ StyleBuilder font(double fontSize) {
+ _style.fontWeight = FontWeight.w400;
+ _style.fontSize = fontSize;
+ _style.height = 16 / _style.fontSize;
+ _style.letterSpacing = _style.fontSize * (-2 / 100);
+ return StyleBuilderImpl(_style);
+ }
}
diff --git a/pubspec.lock b/pubspec.lock
index ff18d37..4e36bdd 100644
--- a/pubspec.lock
+++ b/pubspec.lock
@@ -153,6 +153,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.3.1"
+ calendar_date_picker2:
+ dependency: "direct main"
+ description:
+ name: calendar_date_picker2
+ sha256: "9c9b5586fb512bf1181d7f3a6273bffa9e65a4e16689902e112771e7d71d063b"
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.0.1"
characters:
dependency: transitive
description:
diff --git a/pubspec.yaml b/pubspec.yaml
index 9345a4e..076d763 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -35,6 +35,7 @@ dependencies:
google_maps_flutter: ^2.14.0
package_info_plus: ^9.0.0
webview_flutter: ^4.13.0
+ calendar_date_picker2: ^2.0.1
dev_dependencies:
flutter_test: