diff --git a/lib/presentation/home/home_page_screen.dart b/lib/presentation/home/home_page_screen.dart index 8660512a..2546f6aa 100644 --- a/lib/presentation/home/home_page_screen.dart +++ b/lib/presentation/home/home_page_screen.dart @@ -2,14 +2,17 @@ import 'package:dongsoop/core/presentation/components/admob_native_ad.dart'; import 'package:dongsoop/core/presentation/components/login_required_dialog.dart'; import 'package:dongsoop/presentation/home/widgets/chatbot_button.dart'; import 'package:dongsoop/presentation/home/widgets/home_header.dart'; -import 'package:dongsoop/presentation/home/widgets/home_new_notice.dart'; -import 'package:dongsoop/presentation/home/widgets/home_popular_recruits.dart'; -import 'package:dongsoop/presentation/home/widgets/home_today.dart'; +import 'package:dongsoop/presentation/home/widgets/home_greeting.dart'; +import 'package:dongsoop/presentation/home/widgets/home_meal_section.dart'; +import 'package:dongsoop/presentation/home/widgets/home_notice_list.dart'; +import 'package:dongsoop/presentation/home/widgets/home_quick_links.dart'; +import 'package:dongsoop/presentation/home/widgets/home_today_card.dart'; import 'package:dongsoop/ui/color_styles.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:google_mobile_ads/google_mobile_ads.dart'; import 'package:dongsoop/presentation/home/view_models/notification_badge_view_model.dart'; import 'package:dongsoop/providers/auth_providers.dart'; import 'package:dongsoop/presentation/home/view_models/home_view_model.dart'; @@ -59,9 +62,9 @@ class HomePageScreen extends HookConsumerWidget { onTap: () async { if (user == null) { await LoginRequiredDialog(context); - } else { - onTapChatbot(); // 기존 이동/동작 + return; } + onTapChatbot(); }, ), ), @@ -85,17 +88,30 @@ class HomePageScreen extends HookConsumerWidget { child: ListView( padding: EdgeInsets.zero, children: [ - HomeToday( + HomeGreeting( + classCount: homeEntity.timeTable.length, + scheduleCount: homeEntity.schedule.length, + isLoggedOut: user == null, + ), + HomeTodayCard( timeTable: homeEntity.timeTable, schedule: homeEntity.schedule, isLoggedOut: user == null, ), - HomeNewNotice(notices: homeEntity.notices), + const HomeMealSection(), + HomeNoticeList(notices: homeEntity.notices), const Padding( - padding: EdgeInsets.symmetric(vertical: 16, horizontal: 16), - child: AdmobNativeAd(), + padding: EdgeInsets.symmetric(vertical: 22, horizontal: 16), + child: SizedBox( + height: 100, + child: AdmobNativeAd( + templateType: TemplateType.small, + height: 100, + ), + ), ), - HomePopularRecruits(recruits: homeEntity.popularRecruits), + const HomeQuickLinks(), + const SizedBox(height: 24), ], ), ), diff --git a/lib/presentation/home/widgets/cafeteria_card.dart b/lib/presentation/home/widgets/cafeteria_card.dart deleted file mode 100644 index ab99f1fe..00000000 --- a/lib/presentation/home/widgets/cafeteria_card.dart +++ /dev/null @@ -1,245 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:dongsoop/ui/color_styles.dart'; -import 'package:dongsoop/ui/text_styles.dart'; - -class CafeteriaCard extends StatefulWidget { - const CafeteriaCard({ - super.key, - required this.initialDayIndex, - required this.todayIndex, - required this.dayLabels, - required this.menuByDay, - this.isLoading = false, - this.errorText, - }); - - final int initialDayIndex; - final int todayIndex; - final List dayLabels; - final List menuByDay; - final bool isLoading; - final String? errorText; - - @override - State createState() => _CafeteriaCardState(); -} - -class _CafeteriaCardState extends State { - PageController? _controller; - int _index = 0; - int _virtualIndex = 0; - - int get _len => widget.menuByDay.length; - bool get _hasPages => _len > 0; - bool get _isInteractive => _hasPages && !widget.isLoading && widget.errorText == null; - - @override - void initState() { - super.initState(); - _setupController(); - } - - @override - void didUpdateWidget(covariant CafeteriaCard oldWidget) { - super.didUpdateWidget(oldWidget); - if (oldWidget.menuByDay.length != widget.menuByDay.length || - oldWidget.initialDayIndex != widget.initialDayIndex) { - _setupController(); - } - } - - void _setupController() { - if (_hasPages) { - final upper = _len - 1; - final safeInitial = widget.initialDayIndex.clamp(0, upper); - _index = safeInitial; - - final base = 1000 * _len; - _virtualIndex = base + safeInitial; - - _controller?.dispose(); - _controller = PageController(initialPage: _virtualIndex); - } else { - _index = 0; - _virtualIndex = 0; - _controller?.dispose(); - _controller = null; - } - setState(() {}); - } - - void _prev() { - if (!_isInteractive) return; - if (_controller?.hasClients != true) return; - _virtualIndex--; - _controller!.animateToPage( - _virtualIndex, - duration: const Duration(milliseconds: 220), - curve: Curves.easeOut, - ); - - _index = ((_virtualIndex % _len) + _len) % _len; - setState(() {}); - } - - void _next() { - if (!_isInteractive) return; - if (_controller?.hasClients != true) return; - _virtualIndex++; - _controller!.animateToPage( - _virtualIndex, - duration: const Duration(milliseconds: 220), - curve: Curves.easeOut, - ); - - _index = _virtualIndex % _len; - setState(() {}); - } - - String _titleFor(int page) { - if (page == widget.todayIndex) return '오늘의 학식'; - final label = (page >= 0 && page < widget.dayLabels.length) - ? widget.dayLabels[page] - : ''; - return '$label요일 학식'; - } - - @override - Widget build(BuildContext context) { - final leftEnabled = _isInteractive; - final rightEnabled = _isInteractive; - - String bodyText; - if (widget.isLoading) { - bodyText = '불러오는 중...'; - } else if (widget.errorText != null) { - bodyText = widget.errorText!; - } else if (!_hasPages) { - bodyText = '학식이 제공되지 않아요!'; - } else { - bodyText = widget.menuByDay[_index]; - } - - return Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: ColorStyles.white, - borderRadius: BorderRadius.circular(8), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SizedBox( - height: 24, - child: Row( - children: [ - _ArrowButton( - alignment: Alignment.centerLeft, - onTap: leftEnabled ? _prev : null, - icon: Icons.chevron_left, - ), - Text( - _titleFor(_index), - style: TextStyles.normalTextBold.copyWith(color: ColorStyles.black), - overflow: TextOverflow.ellipsis, - ), - _ArrowButton( - alignment: Alignment.centerRight, - onTap: rightEnabled ? _next : null, - icon: Icons.chevron_right, - ), - ], - ), - ), - const SizedBox(height: 8), - - if (_hasPages && !widget.isLoading && widget.errorText == null) - SizedBox( - height: 44, - child: PageView.builder( - controller: _controller, - physics: const BouncingScrollPhysics(), - onPageChanged: (i) { - _virtualIndex = i; - _index = i % _len; - setState(() {}); - }, - itemBuilder: (_, i) { - final real = i % _len; - return Align( - alignment: Alignment.centerLeft, - child: Text( - widget.menuByDay[real], - style: TextStyles.smallTextRegular.copyWith(color: ColorStyles.gray4), - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - ); - }, - ), - ) - else - SizedBox( - height: 44, - child: Align( - alignment: Alignment.centerLeft, - child: Text( - bodyText, - style: TextStyles.smallTextRegular.copyWith( - color: ColorStyles.gray4, - ), - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - ), - ), - ], - ), - ); - } - - @override - void dispose() { - _controller?.dispose(); - super.dispose(); - } -} - -class _ArrowButton extends StatelessWidget { - const _ArrowButton({ - required this.onTap, - required this.icon, - required this.alignment, - }); - - final VoidCallback? onTap; - final IconData icon; - final Alignment alignment; - - @override - Widget build(BuildContext context) { - final enabled = onTap != null; - final Color base = ColorStyles.gray3; - final Color color = enabled ? base : base.withValues(alpha: 0.8); - - return GestureDetector( - behavior: HitTestBehavior.translucent, - onTap: enabled ? onTap : null, - child: SizedBox( - width: 44, - child: IgnorePointer( - ignoring: !enabled, - child: Align( - alignment: alignment, - child: AnimatedOpacity( - duration: const Duration(milliseconds: 150), - opacity: enabled ? 1.0 : 0.8, - child: Icon(icon, size: 24, color: color), - ), - ), - ), - ), - ); - } -} - diff --git a/lib/presentation/home/widgets/home_greeting.dart b/lib/presentation/home/widgets/home_greeting.dart new file mode 100644 index 00000000..f6dc5ac9 --- /dev/null +++ b/lib/presentation/home/widgets/home_greeting.dart @@ -0,0 +1,86 @@ +import 'package:dongsoop/ui/color_styles.dart'; +import 'package:dongsoop/ui/text_styles.dart'; +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; + +/// 홈 응답의 수업·일정 수로 오늘 상태부터 알리는 첫 문장. +/// +/// 0 인 항목은 문장에서 뺀다. 둘 다 없으면 다른 문장으로 바꾼다 — +/// "수업 0개, 일정 0개" 는 읽는 사람에게 아무것도 알려주지 않는다. +/// +/// 공지는 세지 않는다. 홈 응답의 공지는 서버에서 세 건으로 잘려 오고 +/// (`searchHomeNotices` 의 limit(3)) 읽음 여부도 알 수 없어, 그 길이를 +/// 그대로 쓰면 매일 "새 공지 3개" 가 뜬다. +class HomeGreeting extends StatelessWidget { + final int classCount; + final int scheduleCount; + final bool isLoggedOut; + + const HomeGreeting({ + super.key, + required this.classCount, + required this.scheduleCount, + required this.isLoggedOut, + }); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.fromLTRB(20, 10, 20, 4), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + DateFormat('M월 d일 EEEE', 'ko').format(DateTime.now()), + style: TextStyles.smallTextBold.copyWith(color: ColorStyles.gray5), + ), + const SizedBox(height: 6), + Text.rich( + _buildMessage(), + style: TextStyles.titleTextBold.copyWith( + color: ColorStyles.black, + height: 1.32, + ), + ), + ], + ), + ); + } + + TextSpan _buildMessage() { + if (isLoggedOut) { + return const TextSpan(text: '오늘의 캠퍼스 소식'); + } + + final parts = [ + if (classCount > 0) _countPhrase('오늘 수업 ', classCount), + if (scheduleCount > 0) _countPhrase('일정 ', scheduleCount), + ]; + + if (parts.isEmpty) { + return const TextSpan(text: '오늘은 일정이 없어요'); + } + + final children = []; + for (var i = 0; i < parts.length; i++) { + if (i > 0) children.add(const TextSpan(text: ',\n')); + children.add(parts[i]); + } + children.add(const TextSpan(text: ' 있어요')); + + return TextSpan(children: children); + } + + /// 숫자만 강조색으로 띄워 눈이 먼저 가게 한다. + TextSpan _countPhrase(String label, int count) { + return TextSpan( + children: [ + TextSpan(text: label), + TextSpan( + text: '$count개', + style: const TextStyle(color: ColorStyles.primary100), + ), + ], + ); + } +} diff --git a/lib/presentation/home/widgets/home_meal_section.dart b/lib/presentation/home/widgets/home_meal_section.dart new file mode 100644 index 00000000..ce62e620 --- /dev/null +++ b/lib/presentation/home/widgets/home_meal_section.dart @@ -0,0 +1,87 @@ +import 'package:dongsoop/core/presentation/components/meal_menu_view.dart'; +import 'package:dongsoop/presentation/home/view_models/cafeteria_view_model.dart'; +import 'package:dongsoop/ui/color_styles.dart'; +import 'package:dongsoop/ui/text_styles.dart'; +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +/// 홈의 학식 구획. +/// +/// 오늘 카드에서 떼어 따로 세웠다. 수업·일정은 내 것이고 학식은 학교 것이라 +/// 한 장에 묶으면 카드가 무슨 카드인지 흐려진다. +/// +/// 캠퍼스 학식과 같은 조각(`MealDeck`)을 써서 같은 모양으로 읽히고 같이 +/// 좌우로 넘어간다. 다른 것은 그릇뿐이다 — 여기는 채운 면 대신 왼쪽 세로선을 +/// 쓴다. 바로 위 오늘 카드가 이미 둥근 회색 면이라, 학식까지 면을 깔면 색만 +/// 다른 같은 덩어리 둘이 붙어 있게 된다. +class HomeMealSection extends ConsumerWidget { + const HomeMealSection({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final state = ref.watch(cafeteriaViewModelProvider); + + return Padding( + padding: const EdgeInsets.fromLTRB(20, 22, 20, 0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '학식', + style: TextStyles.largeTextBold.copyWith(color: ColorStyles.black), + ), + const SizedBox(height: 12), + state.when( + data: (data) => _MealFrame( + child: MealDeck( + weekMeals: data.weekMeals, + staples: data.staples, + ), + ), + loading: () => const _MealFrame( + muted: true, + child: MealSkeletonView(), + ), + error: (_, __) => const _MealFrame( + muted: true, + child: MealNoticeView('학식을 불러오지 못했어요'), + ), + ), + ], + ), + ); + } +} + +/// 왼쪽 세로선 + 본문. +/// +/// 선은 회색이다. 오늘 카드와 갈라 보이려고 한때 따뜻한 색을 썼는데, 갈라 +/// 주는 건 색이 아니라 면을 쓰지 않는다는 사실이라 색이 할 일이 없다. +class _MealFrame extends StatelessWidget { + final Widget child; + + /// 보여줄 메뉴가 없는 상태에서는 선도 함께 물러난다 + final bool muted; + + const _MealFrame({required this.child, this.muted = false}); + + @override + Widget build(BuildContext context) { + return IntrinsicHeight( + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Container( + width: 3, + decoration: BoxDecoration( + color: muted ? ColorStyles.gray1 : ColorStyles.gray2, + borderRadius: BorderRadius.circular(2), + ), + ), + const SizedBox(width: 13), + Expanded(child: child), + ], + ), + ); + } +} diff --git a/lib/presentation/home/widgets/home_new_notice.dart b/lib/presentation/home/widgets/home_new_notice.dart deleted file mode 100644 index 293644c9..00000000 --- a/lib/presentation/home/widgets/home_new_notice.dart +++ /dev/null @@ -1,141 +0,0 @@ -import 'package:dongsoop/core/presentation/components/common_tag.dart'; -import 'package:dongsoop/core/presentation/components/notice_setting_link.dart'; -import 'package:dongsoop/core/routing/route_paths.dart'; -import 'package:dongsoop/domain/home/entity/home_entity.dart'; -import 'package:dongsoop/ui/color_styles.dart'; -import 'package:dongsoop/ui/text_styles.dart'; -import 'package:flutter/material.dart'; -import 'package:go_router/go_router.dart'; - -class HomeNewNotice extends StatelessWidget { - const HomeNewNotice({super.key, required this.notices}); - final List notices; - - @override - Widget build(BuildContext context) { - return Container( - color: ColorStyles.gray1, - width: double.infinity, - padding: const EdgeInsets.symmetric(vertical: 24, horizontal: 16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - '새로운 공지', - style: TextStyles.titleTextBold.copyWith( - color: ColorStyles.black, - ), - ), - GestureDetector( - onTap: () => context.goNamed('noticeList'), - child: Container( - height: 44, - padding: const EdgeInsets.symmetric(horizontal: 8), - child: Row( - children: [ - Text( - '더보기', - style: TextStyles.normalTextRegular.copyWith( - color: ColorStyles.gray3, - ), - ), - const SizedBox(width: 4), - Icon( - Icons.arrow_forward_ios, - size: 16, - color: ColorStyles.gray3, - ), - ], - ), - ), - ), - ], - ), - const SizedBox(height: 16), - Container( - width: double.infinity, - decoration: BoxDecoration( - color: ColorStyles.white, - borderRadius: BorderRadius.circular(8), - ), - padding: const EdgeInsets.fromLTRB(16, 32, 16, 24), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _buildNoticeList(context), - const SizedBox(height: 24), - // 관심 학과를 안 고르면 학과 공지가 아예 안 온다. - // 목록을 다 본 자리에 두어야 "학과 공지가 왜 없지" 하는 순간 바로 닿는다 - NoticeSettingLink( - icon: Icons.bookmark, - label: '학과 구독 설정', - onTap: () => - context.push(RoutePaths.subscribeDepartmentSetting), - ), - ], - ), - ), - ], - ), - ); - } - - Widget _buildNoticeList(BuildContext context) { - if (notices.isEmpty) { - return Center( - child: Text( - '새 공지가 없어요', - style: TextStyles.normalTextRegular.copyWith(color: ColorStyles.gray4), - ), - ); - } - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: List.generate(notices.length, (index) { - final item = notices[index]; - final tags = (item.type == NoticeType.department) - ? const ['학과공지', '학부'] - : const ['동양공지', '학교생활']; - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - GestureDetector( - behavior: HitTestBehavior.opaque, - onTap: () => context.pushNamed( - 'noticeWebView', - queryParameters: {'path': item.link}, - ), - child: Text( - item.title, - style: TextStyles.largeTextBold.copyWith(color: ColorStyles.black), - ), - ), - const SizedBox(height: 16), - Wrap( - children: tags - .asMap() - .entries - .map((entry) => CommonTag( - label: entry.value, - index: entry.key, - )) - .toList(), - ), - if (index != notices.length - 1) - Container( - margin: const EdgeInsets.symmetric(vertical: 24), - width: double.infinity, - height: 1, - color: ColorStyles.gray2, - ), - ], - ); - }), - ); - } -} diff --git a/lib/presentation/home/widgets/home_notice_list.dart b/lib/presentation/home/widgets/home_notice_list.dart new file mode 100644 index 00000000..a9d99230 --- /dev/null +++ b/lib/presentation/home/widgets/home_notice_list.dart @@ -0,0 +1,147 @@ +import 'package:dongsoop/core/presentation/components/notice_setting_link.dart'; +import 'package:dongsoop/core/routing/route_paths.dart'; +import 'package:dongsoop/domain/home/entity/home_entity.dart'; +import 'package:dongsoop/ui/color_styles.dart'; +import 'package:dongsoop/ui/text_styles.dart'; +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; + +/// 홈의 새로운 공지 세 건. +class HomeNoticeList extends StatelessWidget { + final List notices; + + const HomeNoticeList({super.key, required this.notices}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.fromLTRB(20, 22, 20, 0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + '새로운 공지', + style: TextStyles.largeTextBold.copyWith(color: ColorStyles.black), + ), + ), + InkWell( + onTap: () => context.goNamed('noticeList'), + borderRadius: BorderRadius.circular(8), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 6), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + '더보기', + style: TextStyles.smallTextRegular.copyWith(color: ColorStyles.gray5), + ), + const SizedBox(width: 2), + const Icon(Icons.arrow_forward_ios, size: 12, color: ColorStyles.gray5), + ], + ), + ), + ), + ], + ), + const SizedBox(height: 4), + if (notices.isEmpty) + Padding( + padding: const EdgeInsets.symmetric(vertical: 20), + child: Text( + '새 공지가 없어요', + style: TextStyles.normalTextRegular.copyWith( + color: ColorStyles.gray4, + ), + ), + ) + else + for (var i = 0; i < notices.length; i++) + _NoticeRow( + notice: notices[i], + isLast: i == notices.length - 1, + ), + const SizedBox(height: 12), + // 관심 학과를 안 고르면 학과 공지가 아예 안 온다. + // 목록을 다 본 자리에 두어야 "학과 공지가 왜 없지" 하는 순간 바로 닿는다 + NoticeSettingLink( + icon: Icons.bookmark, + label: '학과 구독 설정', + onTap: () => context.push(RoutePaths.subscribeDepartmentSetting), + ), + ], + ), + ); + } +} + +class _NoticeRow extends StatelessWidget { + final Notice notice; + final bool isLast; + + const _NoticeRow({ + required this.notice, + required this.isLast, + }); + + @override + Widget build(BuildContext context) { + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => context.pushNamed( + 'noticeWebView', + queryParameters: {'path': notice.link}, + ), + child: Container( + padding: const EdgeInsets.symmetric(vertical: 14), + decoration: BoxDecoration( + border: isLast + ? null + : const Border( + bottom: BorderSide(color: ColorStyles.gray1, width: 1), + ), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 6, + height: 6, + margin: const EdgeInsets.only(top: 8, right: 10), + decoration: BoxDecoration( + color: ColorStyles.primary100, + borderRadius: BorderRadius.circular(3), + ), + ), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + notice.title, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: TextStyles.normalTextBold.copyWith( + color: ColorStyles.black, + height: 1.45, + ), + ), + const SizedBox(height: 4), + Text( + notice.type == NoticeType.department ? '학과공지' : '동양공지', + style: TextStyles.smallTextRegular.copyWith( + color: ColorStyles.gray5, + ), + ), + ], + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/presentation/home/widgets/home_popular_recruits.dart b/lib/presentation/home/widgets/home_popular_recruits.dart deleted file mode 100644 index 1e84f8f3..00000000 --- a/lib/presentation/home/widgets/home_popular_recruits.dart +++ /dev/null @@ -1,160 +0,0 @@ -import 'package:dongsoop/core/routing/route_paths.dart'; -import 'package:dongsoop/domain/home/entity/home_entity.dart'; -import 'package:dongsoop/ui/color_styles.dart'; -import 'package:dongsoop/ui/text_styles.dart'; -import 'package:flutter/material.dart'; -import 'package:go_router/go_router.dart'; -import 'package:dongsoop/core/presentation/components/common_tag.dart'; - -class HomePopularRecruits extends StatelessWidget { - const HomePopularRecruits({super.key, required this.recruits}); - - final List recruits; - - @override - Widget build(BuildContext context) { - final items = recruits; - - return Container( - color: ColorStyles.gray1, - width: double.infinity, - padding: const EdgeInsets.only(top: 32, left: 16, right: 16, bottom: 40), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - '인기 모집', - style: TextStyles.titleTextBold.copyWith( - color: ColorStyles.black, - ), - ), - GestureDetector( - onTap: () { - context.go(RoutePaths.board); - }, - child: Container( - height: 44, - padding: const EdgeInsets.symmetric(horizontal: 8), - child: Row( - children: [ - Text( - '더보기', - style: TextStyles.normalTextRegular.copyWith( - color: ColorStyles.gray3, - ), - ), - const SizedBox(width: 4), - SizedBox( - width: 24, - height: 24, - child: Icon( - Icons.arrow_forward_ios, - size: 16, - color: ColorStyles.gray3, - ), - ), - ], - ), - ), - ), - ], - ), - const SizedBox(height: 16), - Container( - width: double.infinity, - decoration: BoxDecoration( - color: ColorStyles.white, - borderRadius: BorderRadius.circular(8), - ), - padding: - const EdgeInsets.only(top: 32, left: 16, right: 16, bottom: 40), - child: (items.isEmpty) - ? Center( - child: Text( - '지금은 인기 모집 게시글이 없어요', - style: TextStyles.normalTextRegular.copyWith(color: ColorStyles.gray4), - ), - ) - : Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: List.generate(items.length, (index) { - final item = items[index]; - final tags = _splitTags(item.tags); - final volunteerCount = item.volunteer; - - return GestureDetector( - behavior: HitTestBehavior.opaque, - onTap: () { - context.push( - RoutePaths.recruitDetail, - extra: { - 'id': item.id, - 'type': item.type, - }, - ); - }, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Expanded( - child: Text( - item.title, - style: TextStyles.largeTextBold.copyWith(color: ColorStyles.black), - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - ), - Text( - '$volunteerCount명이 지원했어요', - style: TextStyles.smallTextRegular.copyWith(color: ColorStyles.gray4), - textAlign: TextAlign.right, - ), - ], - ), - const SizedBox(height: 8), - Text( - item.content, - style: TextStyles.smallTextRegular.copyWith(color: ColorStyles.black), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - const SizedBox(height: 16), - if (tags.isNotEmpty) - Wrap( - children: tags - .asMap() - .entries - .map((e) => CommonTag(label: e.value, index: e.key)) - .toList(), - ), - if (index != items.length - 1) - Container( - margin: const EdgeInsets.symmetric(vertical: 24), - width: double.infinity, - height: 1, - color: ColorStyles.gray2, - ), - ], - ), - ); - }), - ), - ), - ], - ), - ); - } - - List _splitTags(String raw) => raw - .trim() - .split(RegExp(r'[,,]')) - .map((e) => e.trim()) - .where((e) => e.isNotEmpty) - .toList(growable: false); -} diff --git a/lib/presentation/home/widgets/home_quick_links.dart b/lib/presentation/home/widgets/home_quick_links.dart new file mode 100644 index 00000000..92743e50 --- /dev/null +++ b/lib/presentation/home/widgets/home_quick_links.dart @@ -0,0 +1,127 @@ +import 'package:dongsoop/core/presentation/components/login_required_dialog.dart'; +import 'package:dongsoop/core/routing/route_paths.dart'; +import 'package:dongsoop/providers/auth_providers.dart'; +import 'package:dongsoop/ui/color_styles.dart'; +import 'package:dongsoop/ui/text_styles.dart'; +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +/// 자주 쓰는 화면 네 개. +class HomeQuickLinks extends ConsumerWidget { + const HomeQuickLinks({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final user = ref.watch(userSessionProvider); + + return Padding( + padding: const EdgeInsets.fromLTRB(20, 22, 20, 0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '바로가기', + style: TextStyles.largeTextBold.copyWith(color: ColorStyles.black), + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: _QuickTile( + emoji: '🍽️', + label: '맛집', + background: ColorStyles.primary5, + onTap: () => context.pushNamed('restaurants'), + ), + ), + const SizedBox(width: 8), + Expanded( + child: _QuickTile( + emoji: '📚', + label: '도서관', + background: ColorStyles.mintBg, + onTap: () => context.pushNamed('libraryWebView'), + ), + ), + const SizedBox(width: 8), + Expanded( + child: _QuickTile( + emoji: '💬', + label: '챗봇', + background: ColorStyles.amberBg, + onTap: () async { + if (user == null) { + await LoginRequiredDialog(context); + return; + } + context.push(RoutePaths.chatbot); + }, + ), + ), + const SizedBox(width: 8), + Expanded( + child: _QuickTile( + emoji: '🗓️', + label: '학사일정', + background: ColorStyles.gray1, + onTap: () => context.push(RoutePaths.schedule), + ), + ), + ], + ), + ], + ), + ); + } +} + +class _QuickTile extends StatelessWidget { + final String emoji; + final String label; + final Color background; + final VoidCallback onTap; + + const _QuickTile({ + required this.emoji, + required this.label, + required this.background, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + return Material( + color: ColorStyles.gray7, + borderRadius: BorderRadius.circular(16), + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(16), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 4), + child: Column( + children: [ + Container( + width: 34, + height: 34, + decoration: BoxDecoration( + color: background, + borderRadius: BorderRadius.circular(12), + ), + alignment: Alignment.center, + child: Text(emoji, style: const TextStyle(fontSize: 17)), + ), + const SizedBox(height: 7), + Text( + label, + style: TextStyles.smallTextBold.copyWith( + color: ColorStyles.gray6, + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/presentation/home/widgets/home_today.dart b/lib/presentation/home/widgets/home_today.dart deleted file mode 100644 index c64cf205..00000000 --- a/lib/presentation/home/widgets/home_today.dart +++ /dev/null @@ -1,334 +0,0 @@ -import 'package:dongsoop/presentation/home/view_models/cafeteria_view_model.dart'; -import 'package:dongsoop/ui/color_styles.dart'; -import 'package:dongsoop/ui/text_styles.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_svg/flutter_svg.dart'; -import 'package:go_router/go_router.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:dongsoop/domain/home/entity/home_entity.dart'; -import 'package:dongsoop/presentation/home/widgets/cafeteria_card.dart'; - -class HomeToday extends HookConsumerWidget { - const HomeToday({ - super.key, - required this.timeTable, - required this.schedule, - required this.isLoggedOut, - }); - - final List timeTable; - final List schedule; - final bool isLoggedOut; - - @override - Widget build(BuildContext context, WidgetRef ref) { - final cafeteriaState = ref.watch(cafeteriaViewModelProvider); - final now = DateTime.now(); - final weekdayNames = ['월', '화', '수', '목', '금', '토', '일']; - final weekdayIndex = (now.weekday - 1).clamp(0, 6); - final todayString = '${now.month}월 ${now.day}일 (${weekdayNames[weekdayIndex]})'; - - return Container( - width: double.infinity, - padding: const EdgeInsets.symmetric(vertical: 24, horizontal: 16), - decoration: const BoxDecoration( - color: ColorStyles.gray1, - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [ColorStyles.white, ColorStyles.gray1], - ), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - todayString, - style: TextStyles.titleTextBold.copyWith( - color: ColorStyles.black, - ), - ), - const SizedBox(height: 16), - - IntrinsicHeight( - child: Row( - children: [ - if (!isLoggedOut) ...[ - Expanded( - child: _buildCard( - title: '강의시간표', - type: _CardType.timetable, - context: context, - slots: timeTable, - isLoggedOut: isLoggedOut, - ), - ), - const SizedBox(width: 8), - ], - Expanded( - child: _buildCard( - title: '일정', - type: _CardType.schedule, - context: context, - schedule: schedule, - isLoggedOut: isLoggedOut, - ), - ), - ], - ), - ), - const SizedBox(height: 16), - - cafeteriaState.when( - data: (state) { - final menus = state.weekMeals.map((m) => m.koreanMenu).toList(growable: false); - final todayIndex = weekdayIndex; - return CafeteriaCard( - initialDayIndex: todayIndex, - todayIndex: todayIndex, - dayLabels: const ['월', '화', '수', '목', '금', '토', '일'], - menuByDay: menus, - ); - }, - loading: () => const CafeteriaCard( - initialDayIndex: 0, - todayIndex: 0, - dayLabels: ['월','화','수','목','금','토','일'], - menuByDay: [], - isLoading: true, - ), - error: (err, _) => CafeteriaCard( - initialDayIndex: 0, - todayIndex: 0, - dayLabels: const ['월','화','수','목','금','토','일'], - menuByDay: [], - errorText: err.toString(), - ), - ), - const SizedBox(height: 16), - - _buildCard( - title: '', - type: _CardType.banner, - context: context, - isLoggedOut: isLoggedOut, - ), - ], - ), - ); - } - - static String formatHourMinute(String value) { - final match = RegExp(r'^\s*(\d{1,2}):(\d{2})(?::\d{2})?\s*$').firstMatch(value); - if (match != null) { - final hourPart = match.group(1)!.padLeft(2, '0'); - final minutePart = match.group(2)!; - return '$hourPart:$minutePart'; - } - return value; - } - - static String _displayTimeForSchedule(Schedule s) { - final isOfficial = s.type == ScheduleType.official; - return isOfficial ? '학사' : formatHourMinute(s.startAt); - } - - static Widget _buildCard({ - required String title, - required _CardType type, - required BuildContext context, - List slots = const [], - List schedule = const [], - bool isLoggedOut = false, - }) { - List content = []; - - if (type == _CardType.timetable) { - content = slots.isEmpty - ? [ - Text( - '오늘 수업이 없어요', - style: TextStyles.smallTextRegular.copyWith(color: ColorStyles.gray4), - ), - ] - : slots - .take(3) - .map((s) => _buildRow(formatHourMinute(s.startAt), s.title)) - .toList(); - - } else if (type == _CardType.schedule) { - if (isLoggedOut) { - final officialOnly = schedule - .where((s) => s.type == ScheduleType.official) - .toList(); - - content = officialOnly.isEmpty - ? [ - Text( - '오늘 학사 일정이 없어요', - style: TextStyles.smallTextRegular.copyWith(color: ColorStyles.gray4), - ), - ] - : officialOnly - .take(3) - .map((c) => _buildRow('학사', c.title)) - .toList(); - } else { - content = schedule.isEmpty - ? [ - Text( - '오늘 일정이 없어요', - style: TextStyles.smallTextRegular.copyWith(color: ColorStyles.gray4), - ), - ] - : schedule - .take(3) - .map((c) => _buildRow(_displayTimeForSchedule(c), c.title)) - .toList(); - } - } - - if (type == _CardType.banner) { - return Column( - spacing: 16, - children: [ - GestureDetector( - onTap: () => context.goNamed('restaurants'), - behavior: HitTestBehavior.opaque, - child: Image.asset( - 'assets/images/restaurant_banner.png', - width: double.infinity, - fit: BoxFit.cover, - ), - ), - GestureDetector( - onTap: () => context.goNamed('libraryWebView'), - child: Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: ColorStyles.white, - borderRadius: BorderRadius.circular(8), - ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Padding( - padding: const EdgeInsets.only(right: 24), - child: SvgPicture.asset( - 'assets/icons/book.svg', - width: 24, - height: 24, - ), - ), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - '팀원들과 시너지를 올릴 공간이 필요하신가요?', - style: TextStyles.smallTextRegular.copyWith( - color: ColorStyles.black, - ), - ), - Text.rich( - TextSpan( - children: [ - TextSpan( - text: '도서관 스터디룸', - style: TextStyles.smallTextBold.copyWith( - color: ColorStyles.primaryColor, - ), - ), - TextSpan( - text: '을 예약해 보세요', - style: TextStyles.smallTextRegular.copyWith( - color: ColorStyles.black, - ), - ), - ], - ), - ), - ], - ), - ), - Padding( - padding: const EdgeInsets.only(left: 24), - child: Icon( - Icons.chevron_right, - size: 24, - color: ColorStyles.gray3, - ), - ), - ], - ), - ), - ), - ], - ); - } - - return GestureDetector( - onTap: () { - switch (type) { - case _CardType.timetable: - context.push('/timetable'); - break; - case _CardType.schedule: - context.push('/schedule'); - break; - case _CardType.banner: - break; - } - }, - child: Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: ColorStyles.white, - borderRadius: BorderRadius.circular(8), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SizedBox( - height: 24, - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text(title, style: TextStyles.normalTextBold.copyWith(color: ColorStyles.black)), - const Icon(Icons.chevron_right, size: 24, color: ColorStyles.gray3), - ], - ), - ), - SizedBox(height: 8), - ...content, - ], - ), - ), - ); - } - - static Widget _buildRow(String time, String subject) { - return Padding( - padding: const EdgeInsets.only(bottom: 4), - child: Row( - children: [ - Text( - time, - style: TextStyles.smallTextRegular.copyWith(color: ColorStyles.gray4), - ), - const SizedBox(width: 8), - Expanded( - child: Text( - subject, - style: TextStyles.smallTextRegular.copyWith(color: ColorStyles.black), - overflow: TextOverflow.ellipsis, - maxLines: 1, - ), - ), - ], - ), - ); - } -} - -enum _CardType { timetable, schedule, banner } diff --git a/lib/presentation/home/widgets/home_today_card.dart b/lib/presentation/home/widgets/home_today_card.dart new file mode 100644 index 00000000..62db0ba6 --- /dev/null +++ b/lib/presentation/home/widgets/home_today_card.dart @@ -0,0 +1,117 @@ +import 'package:dongsoop/core/routing/route_paths.dart'; +import 'package:dongsoop/domain/home/entity/home_entity.dart'; +import 'package:dongsoop/presentation/home/widgets/home_today_row.dart'; +import 'package:dongsoop/core/presentation/components/swipe_deck.dart'; +import 'package:dongsoop/ui/color_styles.dart'; +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; + +/// 오늘 챙길 것 한 장 — 수업과 일정. +/// +/// `오늘` 제목과 `시간표 ›` 버튼은 두지 않는다. 각 칸이 그대로 링크이고 +/// 우측 셰브런만 그 사실을 알린다. 제목 줄을 없애면 카드가 한 뼘 짧아진다. +/// +/// 학식은 `HomeMealSection` 으로 따로 뺐다. 수업·일정은 내 것이고 학식은 +/// 학교 것이라, 한 장에 묶으면 이 카드가 무슨 카드인지 흐려진다. +class HomeTodayCard extends StatelessWidget { + final List timeTable; + final List schedule; + final bool isLoggedOut; + + const HomeTodayCard({ + super.key, + required this.timeTable, + required this.schedule, + required this.isLoggedOut, + }); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.fromLTRB(20, 22, 20, 0), + child: Container( + decoration: BoxDecoration( + color: ColorStyles.gray1, + borderRadius: BorderRadius.circular(20), + ), + padding: const EdgeInsets.symmetric(vertical: 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (!isLoggedOut) ...[ + SwipeDeck( + itemCount: timeTable.isEmpty ? 1 : timeTable.length, + itemBuilder: (context, index) => timeTable.isEmpty + ? const HomeTodayRow( + emoji: '📘', + background: ColorStyles.primary5, + title: '오늘은 수업이 없어요', + isMuted: true, + ) + : _classRow(timeTable[index]), + onTapItem: () => context.push(RoutePaths.timetable), + ), + const Padding( + padding: EdgeInsets.fromLTRB(16, 14, 16, 14), + child: + Divider(height: 1, thickness: 1, color: ColorStyles.gray2), + ), + ], + _scheduleDeck(context), + ], + ), + ), + ); + } + + /// 오늘 일정. 비회원에게는 개인 일정이 없으므로 학사 일정만 남긴다. + Widget _scheduleDeck(BuildContext context) { + final items = isLoggedOut + ? schedule.where((s) => s.type == ScheduleType.official).toList() + : schedule; + + return SwipeDeck( + itemCount: items.isEmpty ? 1 : items.length, + itemBuilder: (context, index) => items.isEmpty + ? HomeTodayRow( + emoji: '🗓️', + background: ColorStyles.mintBg, + title: isLoggedOut ? '오늘 학사 일정이 없어요' : '오늘 일정이 없어요', + isMuted: true, + ) + : _scheduleRow(items[index]), + onTapItem: () => context.push(RoutePaths.schedule), + ); + } + + Widget _scheduleRow(Schedule item) { + return HomeTodayRow( + emoji: '🗓️', + background: ColorStyles.mintBg, + title: item.title, + description: item.type == ScheduleType.official + ? '학사' + : formatHourMinute(item.startAt), + showChevron: true, + ); + } + + Widget _classRow(Slot slot) { + return HomeTodayRow( + emoji: '📘', + background: ColorStyles.primary5, + title: slot.title, + description: + '${formatHourMinute(slot.startAt)} - ${formatHourMinute(slot.endAt)}', + showChevron: true, + ); + } +} + +/// 기존 HomeToday 에서 쓰던 서버 시간 표시 형식. 서버가 초까지 내려준다. +String formatHourMinute(String value) { + final match = + RegExp(r'^\s*(\d{1,2}):(\d{2})(?::\d{2})?\s*$').firstMatch(value); + if (match == null) return value; + return '${match.group(1)!.padLeft(2, '0')}:${match.group(2)!}'; +} diff --git a/lib/presentation/home/widgets/home_today_row.dart b/lib/presentation/home/widgets/home_today_row.dart new file mode 100644 index 00000000..9bd94528 --- /dev/null +++ b/lib/presentation/home/widgets/home_today_row.dart @@ -0,0 +1,82 @@ +import 'package:dongsoop/ui/color_styles.dart'; +import 'package:dongsoop/ui/text_styles.dart'; +import 'package:flutter/material.dart'; + +/// 오늘 카드와 학식 카드가 함께 쓰는 한 줄. +/// +/// 왼쪽 색 면과 이모지로 종류를 구분한다. 아이콘을 새로 그리지 않는다. +class HomeTodayRow extends StatelessWidget { + final String emoji; + final Color background; + final String title; + final String? description; + final bool showChevron; + + /// 비어 있음을 알리는 줄은 흐리게 둔다 + final bool isMuted; + + const HomeTodayRow({ + super.key, + required this.emoji, + required this.background, + required this.title, + this.description, + this.showChevron = false, + this.isMuted = false, + }); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Row( + children: [ + Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: background, + borderRadius: BorderRadius.circular(13), + ), + alignment: Alignment.center, + child: Text(emoji, style: const TextStyle(fontSize: 19)), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: TextStyles.normalTextBold.copyWith( + color: isMuted ? ColorStyles.gray4 : ColorStyles.black, + height: 1.4, + ), + ), + if (description != null) ...[ + const SizedBox(height: 3), + Text( + description!, + style: TextStyles.smallTextRegular.copyWith( + color: ColorStyles.gray6, + ), + ), + ], + ], + ), + ), + if (showChevron) ...[ + const SizedBox(width: 8), + const Icon( + Icons.arrow_forward_ios, + size: 14, + color: ColorStyles.gray4, + ), + ], + ], + ), + ); + } +}