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: