From da523f380d7c457460e7843eeaf9ad276d3db887 Mon Sep 17 00:00:00 2001 From: rdyjun Date: Sat, 5 Sep 2026 12:00:53 +0900 Subject: [PATCH 01/40] =?UTF-8?q?feat:=20=ED=99=88=EC=9D=84=20=EC=98=A4?= =?UTF-8?q?=EB=8A=98=C2=B7=EA=B3=B5=EC=A7=80=C2=B7=EB=B0=94=EB=A1=9C?= =?UTF-8?q?=EA=B0=80=EA=B8=B0=20=EC=A4=91=EC=8B=AC=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EC=9E=AC=EA=B5=AC=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 목록을 읽기 전에 오늘 상태부터 알리도록 첫 문장을 둔다. 0 인 항목은 문장에서 빼고 둘 다 없으면 "오늘은 일정이 없어요" 로 바꾼다 — "수업 0개, 공지 0개" 는 읽는 사람에게 아무것도 알려주지 않는다. 오늘 카드 - `오늘` 제목과 `시간표 ›` 헤더를 없앴다. 수업 칸 자체가 시간표로 가는 링크이고 우측 셰브런만 그 사실을 알린다. 제목 줄이 빠져 카드가 한 뼘 짧아진다 - 수업과 학식을 좌우로 넘긴다. BouncingScrollPhysics 라 넘길 게 하나뿐이어도 끌리고 튕겨 돌아온다. 넘길 곳이라는 것과 넘길 게 없다는 것을 함께 알려주므로 안내 문구를 따로 두지 않았다 - 학식 제목은 넘긴 날짜를 따라간다. 서버가 dayOfWeek 를 주므로 요일을 계산하지 않는다 - 대표 메뉴를 뽑지 않고 전부 같은 무게로 나열한다. 어떤 메뉴가 앞에 올지 데이터가 보장하지 않아 밥이나 김치가 대표로 보일 수 있다 광고를 세로 250 네이티브에서 가로 배너로 바꿨다. 공지와 바로가기 사이를 크게 가르던 것이 목록 흐름을 끊었다. 광고가 안 붙으면 아무것도 그리지 않는다. 인기 모집은 게시판과 함께 홈에서 뺐다. 안 쓰이게 된 home_today, home_new_notice, home_popular_recruits 를 지웠다. 공지 앞의 점은 읽음 여부 자리다. HomeNotice 응답에 공지 id 가 없어 로컬 읽음 기록과 대조할 수 없으므로 지금은 전부 안 읽음으로 둔다. 백엔드에서 id 가 내려오면 isUnread 만 채우면 된다. --- .../components/admob_banner_ad.dart | 111 ++++++ lib/presentation/home/home_page_screen.dart | 26 +- .../home/widgets/home_greeting.dart | 80 +++++ .../home/widgets/home_new_notice.dart | 141 -------- .../home/widgets/home_notice_list.dart | 158 +++++++++ .../home/widgets/home_popular_recruits.dart | 160 --------- .../home/widgets/home_quick_links.dart | 95 +++++ lib/presentation/home/widgets/home_today.dart | 334 ------------------ .../home/widgets/home_today_card.dart | 208 +++++++++++ lib/presentation/home/widgets/swipe_deck.dart | 89 +++++ 10 files changed, 755 insertions(+), 647 deletions(-) create mode 100644 lib/core/presentation/components/admob_banner_ad.dart create mode 100644 lib/presentation/home/widgets/home_greeting.dart delete mode 100644 lib/presentation/home/widgets/home_new_notice.dart create mode 100644 lib/presentation/home/widgets/home_notice_list.dart delete mode 100644 lib/presentation/home/widgets/home_popular_recruits.dart create mode 100644 lib/presentation/home/widgets/home_quick_links.dart delete mode 100644 lib/presentation/home/widgets/home_today.dart create mode 100644 lib/presentation/home/widgets/home_today_card.dart create mode 100644 lib/presentation/home/widgets/swipe_deck.dart diff --git a/lib/core/presentation/components/admob_banner_ad.dart b/lib/core/presentation/components/admob_banner_ad.dart new file mode 100644 index 00000000..dd79eb4a --- /dev/null +++ b/lib/core/presentation/components/admob_banner_ad.dart @@ -0,0 +1,111 @@ +import 'dart:io'; +import 'package:dongsoop/core/environment/app_distribution.dart'; +import 'package:dongsoop/ui/color_styles.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_dotenv/flutter_dotenv.dart'; +import 'package:google_mobile_ads/google_mobile_ads.dart'; +import 'package:logger/logger.dart'; + +/// 가로형 배너 광고. +/// +/// 홈에서 쓰던 [AdmobNativeAd] 는 세로 250 이라 공지와 바로가기 사이를 크게 갈랐다. +/// 목록 흐름을 끊지 않도록 화면 폭에 맞춘 배너로 바꾼다. +/// +/// 광고가 아직 안 붙었거나 실패하면 아무것도 그리지 않는다 — 빈 회색 칸이 남으면 +/// 로딩이 끝나지 않은 것처럼 보인다. +class AdmobBannerAd extends StatefulWidget { + const AdmobBannerAd({super.key}); + + @override + State createState() => _AdmobBannerAdState(); +} + +class _AdmobBannerAdState extends State { + BannerAd? _bannerAd; + bool _isLoaded = false; + bool _useTestAds = kDebugMode; + + final Logger _logger = Logger(); + + static const String _androidTestAdUnitId = + 'ca-app-pub-3940256099942544/6300978111'; + static const String _iosTestAdUnitId = + 'ca-app-pub-3940256099942544/2934735716'; + + String get _adUnitId { + if (Platform.isAndroid) { + if (_useTestAds) return _androidTestAdUnitId; + return dotenv.maybeGet('ADMOB_ANDROID_BANNER_ID') ?? _androidTestAdUnitId; + } else if (Platform.isIOS) { + if (_useTestAds) return _iosTestAdUnitId; + return dotenv.maybeGet('ADMOB_IOS_BANNER_ID') ?? _iosTestAdUnitId; + } + + return ''; + } + + @override + void initState() { + super.initState(); + _initAndLoadAd(); + } + + Future _initAndLoadAd() async { + final isTestFlight = await AppDistribution.isTestFlight(); + _useTestAds = kDebugMode || isTestFlight; + if (!mounted) return; + _loadAd(); + } + + void _loadAd() { + final adUnitId = _adUnitId; + if (adUnitId.isEmpty) return; + + final ad = BannerAd( + adUnitId: adUnitId, + size: AdSize.banner, + request: const AdRequest(), + listener: BannerAdListener( + onAdLoaded: (_) { + if (!mounted) return; + setState(() => _isLoaded = true); + }, + onAdFailedToLoad: (ad, error) { + _logger.d('AdMob banner failed: $error'); + ad.dispose(); + if (!mounted) return; + setState(() { + _bannerAd = null; + _isLoaded = false; + }); + }, + ), + ); + + _bannerAd = ad; + ad.load(); + } + + @override + void dispose() { + _bannerAd?.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final ad = _bannerAd; + if (!_isLoaded || ad == null) { + return const SizedBox.shrink(); + } + + return Container( + alignment: Alignment.center, + color: ColorStyles.white, + width: ad.size.width.toDouble(), + height: ad.size.height.toDouble(), + child: AdWidget(ad: ad), + ); + } +} diff --git a/lib/presentation/home/home_page_screen.dart b/lib/presentation/home/home_page_screen.dart index 8660512a..e705a230 100644 --- a/lib/presentation/home/home_page_screen.dart +++ b/lib/presentation/home/home_page_screen.dart @@ -1,10 +1,11 @@ -import 'package:dongsoop/core/presentation/components/admob_native_ad.dart'; +import 'package:dongsoop/core/presentation/components/admob_banner_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_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'; @@ -85,17 +86,18 @@ class HomePageScreen extends HookConsumerWidget { child: ListView( padding: EdgeInsets.zero, children: [ - HomeToday( - timeTable: homeEntity.timeTable, - schedule: homeEntity.schedule, - isLoggedOut: user == null, + HomeGreeting( + classCount: homeEntity.timeTable.length, + noticeCount: homeEntity.notices.length, ), - HomeNewNotice(notices: homeEntity.notices), + HomeTodayCard(timeTable: homeEntity.timeTable), + HomeNoticeList(notices: homeEntity.notices), const Padding( - padding: EdgeInsets.symmetric(vertical: 16, horizontal: 16), - child: AdmobNativeAd(), + padding: EdgeInsets.symmetric(vertical: 22), + child: AdmobBannerAd(), ), - HomePopularRecruits(recruits: homeEntity.popularRecruits), + const HomeQuickLinks(), + const SizedBox(height: 24), ], ), ), diff --git a/lib/presentation/home/widgets/home_greeting.dart b/lib/presentation/home/widgets/home_greeting.dart new file mode 100644 index 00000000..bf3013a8 --- /dev/null +++ b/lib/presentation/home/widgets/home_greeting.dart @@ -0,0 +1,80 @@ +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개" 는 읽는 사람에게 아무것도 알려주지 않는다. +class HomeGreeting extends StatelessWidget { + final int classCount; + final int noticeCount; + + const HomeGreeting({ + super.key, + required this.classCount, + required this.noticeCount, + }); + + @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() { + final parts = []; + + if (classCount > 0) { + parts.add(_countPhrase('오늘 수업 ', classCount)); + } + if (noticeCount > 0) { + parts.add(_countPhrase('새 공지 ', noticeCount)); + } + + 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: TextStyle(color: ColorStyles.primary100), + ), + ], + ); + } +} 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..af5204af --- /dev/null +++ b/lib/presentation/home/widgets/home_notice_list.dart @@ -0,0 +1,158 @@ +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'; + +/// 홈의 새로운 공지 세 건. +/// +/// 앞의 점은 읽음 여부 자리다. 지금은 서버 응답(`HomeNotice`)에 공지 id 가 없어 +/// 로컬 읽음 기록과 대조할 수 없으므로 전부 안 읽음으로 둔다. +/// 백엔드에서 id 가 내려오면 [isUnread] 만 채우면 된다. +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( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + '새로운 공지', + style: TextStyles.largeTextBold.copyWith( + color: ColorStyles.black, + ), + ), + GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => context.goNamed('noticeList'), + child: Row( + 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; + + /// 서버가 공지 id 를 내려주기 전까지는 모두 안 읽음으로 본다. + final bool isUnread; + + const _NoticeRow({ + required this.notice, + required this.isLast, + this.isUnread = true, + }); + + @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: isUnread ? ColorStyles.primary100 : ColorStyles.gray2, + borderRadius: BorderRadius.circular(3), + ), + ), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + notice.title, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: TextStyles.normalTextBold.copyWith( + color: isUnread ? ColorStyles.black : ColorStyles.gray6, + 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..1168e59b --- /dev/null +++ b/lib/presentation/home/widgets/home_quick_links.dart @@ -0,0 +1,95 @@ +import 'package:dongsoop/core/routing/route_paths.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 HomeQuickLinks extends StatelessWidget { + const HomeQuickLinks({super.key}); + + @override + Widget build(BuildContext context) { + final items = <_QuickItem>[ + _QuickItem('🍽️', '맛집', ColorStyles.primary5, RoutePaths.restaurants), + _QuickItem('📚', '도서관', const Color(0xFFE4F7EE), RoutePaths.libraryWebView), + _QuickItem('💬', '챗봇', const Color(0xFFFFF6E0), RoutePaths.chatbot), + _QuickItem('🗓️', '학사일정', ColorStyles.gray1, RoutePaths.schedule), + ]; + + 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: [ + for (var i = 0; i < items.length; i++) ...[ + if (i > 0) const SizedBox(width: 8), + Expanded(child: _QuickTile(item: items[i])), + ], + ], + ), + ], + ), + ); + } +} + +class _QuickItem { + final String emoji; + final String label; + final Color background; + final String path; + + const _QuickItem(this.emoji, this.label, this.background, this.path); +} + +class _QuickTile extends StatelessWidget { + final _QuickItem item; + + const _QuickTile({required this.item}); + + @override + Widget build(BuildContext context) { + return Material( + color: ColorStyles.gray7, + borderRadius: BorderRadius.circular(16), + child: InkWell( + onTap: () => context.push(item.path), + 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: item.background, + borderRadius: BorderRadius.circular(12), + ), + alignment: Alignment.center, + child: Text(item.emoji, style: const TextStyle(fontSize: 17)), + ), + const SizedBox(height: 7), + Text( + item.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..e200d6f4 --- /dev/null +++ b/lib/presentation/home/widgets/home_today_card.dart @@ -0,0 +1,208 @@ +import 'package:dongsoop/core/routing/route_paths.dart'; +import 'package:dongsoop/domain/cafeteria/entities/cafeteria_entity.dart'; +import 'package:dongsoop/domain/home/entity/home_entity.dart'; +import 'package:dongsoop/presentation/home/view_models/cafeteria_view_model.dart'; +import 'package:dongsoop/presentation/home/widgets/swipe_deck.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:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:intl/intl.dart'; + +/// 오늘 챙길 것 한 장 — 수업과 학식. +/// +/// `오늘` 제목과 `시간표 ›` 버튼은 두지 않는다. 수업 칸 자체가 시간표로 가는 링크이고 +/// 우측 셰브런만 그 사실을 알린다. 제목 줄을 없애면 카드가 한 뼘 짧아진다. +class HomeTodayCard extends HookConsumerWidget { + final List timeTable; + + const HomeTodayCard({super.key, required this.timeTable}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final mealIndex = useState(0); + final meals = ref.watch(cafeteriaViewModelProvider).maybeWhen( + data: (data) => data.weekMeals, + orElse: () => const [], + ); + + 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: [ + SwipeDeck( + itemCount: timeTable.isEmpty ? 1 : timeTable.length, + itemBuilder: (context, index) => timeTable.isEmpty + ? const _TodayRow( + emoji: '📘', + background: ColorStyles.primary5, + title: '오늘은 수업이 없어요', + ) + : _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), + ), + _MealHeader(meals: meals, index: mealIndex.value), + SwipeDeck( + itemCount: meals.isEmpty ? 1 : meals.length, + onPageChanged: (index) => mealIndex.value = index, + itemBuilder: (context, index) => meals.isEmpty + ? const _TodayRow( + emoji: '🍚', + background: ColorStyles.gray2, + title: '이번 주 학식 정보가 없어요', + ) + : _TodayRow( + emoji: '🍚', + background: const Color(0xFFFFF6E0), + title: meals[index].koreanMenu, + ), + ), + ], + ), + ), + ); + } + + Widget _classRow(Slot slot) { + return _TodayRow( + emoji: '📘', + background: ColorStyles.primary5, + title: slot.title, + description: '${slot.startAt} - ${slot.endAt}', + showChevron: true, + ); + } +} + +/// 학식 제목. 넘긴 날짜를 따라 `학식 · 9월 5일(금)` 로 바뀐다. +class _MealHeader extends StatelessWidget { + final List meals; + final int index; + + const _MealHeader({required this.meals, required this.index}); + + @override + Widget build(BuildContext context) { + final label = _dateLabel(index); + return Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 10), + child: Text.rich( + TextSpan( + style: TextStyles.normalTextBold.copyWith( + color: ColorStyles.black, + ), + children: [ + const TextSpan(text: '학식'), + if (label != null) ...[ + TextSpan( + text: ' · ', + style: TextStyle(color: ColorStyles.gray4), + ), + TextSpan( + text: label, + style: TextStyle(color: ColorStyles.gray6), + ), + ], + ], + ), + ), + ); + } + + /// 서버가 `dayOfWeek` 를 함께 내려주므로 요일을 따로 계산하지 않는다. + String? _dateLabel(int index) { + if (meals.isEmpty) return null; + final meal = meals[index.clamp(0, meals.length - 1)]; + + final parsed = DateTime.tryParse(meal.date); + if (parsed == null) return meal.date; + + return '${DateFormat('M월 d일', 'ko').format(parsed)}(${meal.dayOfWeek})'; + } +} + +class _TodayRow extends StatelessWidget { + final String emoji; + final Color background; + final String title; + final String? description; + final bool showChevron; + + const _TodayRow({ + required this.emoji, + required this.background, + required this.title, + this.description, + this.showChevron = 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: ColorStyles.gray7 == background + ? 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, + ), + ], + ], + ), + ); + } +} diff --git a/lib/presentation/home/widgets/swipe_deck.dart b/lib/presentation/home/widgets/swipe_deck.dart new file mode 100644 index 00000000..e58b2cc3 --- /dev/null +++ b/lib/presentation/home/widgets/swipe_deck.dart @@ -0,0 +1,89 @@ +import 'package:dongsoop/ui/color_styles.dart'; +import 'package:flutter/material.dart'; + +/// 좌우로 넘기는 카드 한 줄과 점 인디케이터. +/// +/// 넘길 게 하나뿐이어도 끌리도록 [BouncingScrollPhysics] 를 쓴다. +/// 끝에서 고무줄처럼 딸려왔다 돌아오면 "여기는 넘기는 곳" 이라는 게 전해지고, +/// 넘길 게 없다는 것도 같이 알려준다. 안내 문구를 따로 두지 않는 이유다. +class SwipeDeck extends StatefulWidget { + final int itemCount; + final IndexedWidgetBuilder itemBuilder; + final ValueChanged? onPageChanged; + final VoidCallback? onTapItem; + + const SwipeDeck({ + super.key, + required this.itemCount, + required this.itemBuilder, + this.onPageChanged, + this.onTapItem, + }); + + @override + State createState() => _SwipeDeckState(); +} + +class _SwipeDeckState extends State { + late final PageController _controller = PageController(); + int _index = 0; + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Column( + children: [ + SizedBox( + height: 52, + child: PageView.builder( + controller: _controller, + physics: const BouncingScrollPhysics(), + itemCount: widget.itemCount, + onPageChanged: (index) { + setState(() => _index = index); + widget.onPageChanged?.call(index); + }, + itemBuilder: (context, index) { + final child = Align( + alignment: Alignment.centerLeft, + child: widget.itemBuilder(context, index), + ); + + if (widget.onTapItem == null) return child; + + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: widget.onTapItem, + child: child, + ); + }, + ), + ), + if (widget.itemCount > 1) ...[ + const SizedBox(height: 12), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: List.generate(widget.itemCount, (i) { + final isCurrent = i == _index; + return AnimatedContainer( + duration: const Duration(milliseconds: 200), + margin: const EdgeInsets.symmetric(horizontal: 2.5), + width: isCurrent ? 14 : 5, + height: 5, + decoration: BoxDecoration( + color: isCurrent ? ColorStyles.gray5 : ColorStyles.gray2, + borderRadius: BorderRadius.circular(3), + ), + ); + }), + ), + ], + ], + ); + } +} From 727399307d085164e46b85dc1c0e1ebe0715f28c Mon Sep 17 00:00:00 2001 From: rdyjun Date: Sat, 5 Sep 2026 13:49:23 +0900 Subject: [PATCH 02/40] =?UTF-8?q?refactor:=20=EB=B0=B0=EB=84=88=20?= =?UTF-8?q?=EA=B4=91=EA=B3=A0=EB=A5=BC=20=EA=B3=A0=EC=A0=95=20320x50=20?= =?UTF-8?q?=EC=97=90=EC=84=9C=20anchored=20adaptive=20=EB=A1=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 고정 AdSize.banner 도 지원되지만 구글이 권하는 방식이 아니다. 폭이 390~430 인 요즘 기기에서 320 고정이면 양옆에 70~110 이 빈 채 남는다. 기기 폭을 넘기면 그에 맞는 높이를 돌려주므로 폭을 꽉 채운다. adaptive 크기를 구하려면 화면 폭이 필요해 로딩 시작을 initState 에서 didChangeDependencies 로 옮기고 한 번만 돌게 막았다. 기기가 크기를 못 주면 표준 배너로 떨어진다. --- .../components/admob_banner_ad.dart | 32 ++++++++++++++----- 1 file changed, 24 insertions(+), 8 deletions(-) diff --git a/lib/core/presentation/components/admob_banner_ad.dart b/lib/core/presentation/components/admob_banner_ad.dart index dd79eb4a..303bbb34 100644 --- a/lib/core/presentation/components/admob_banner_ad.dart +++ b/lib/core/presentation/components/admob_banner_ad.dart @@ -9,9 +9,13 @@ import 'package:logger/logger.dart'; /// 가로형 배너 광고. /// -/// 홈에서 쓰던 [AdmobNativeAd] 는 세로 250 이라 공지와 바로가기 사이를 크게 갈랐다. +/// 홈에서 쓰던 세로 250 네이티브 광고는 공지와 바로가기 사이를 크게 갈랐다. /// 목록 흐름을 끊지 않도록 화면 폭에 맞춘 배너로 바꾼다. /// +/// 고정 320x50(`AdSize.banner`) 대신 **anchored adaptive** 를 쓴다. 구글이 권하는 +/// 방식이고, 폭이 390~430 인 요즘 기기에서 고정 320 을 쓰면 양옆에 빈 자리가 남는다. +/// 기기 폭을 넘기면 그에 맞는 높이를 돌려준다. +/// /// 광고가 아직 안 붙었거나 실패하면 아무것도 그리지 않는다 — 빈 회색 칸이 남으면 /// 로딩이 끝나지 않은 것처럼 보인다. class AdmobBannerAd extends StatefulWidget { @@ -45,26 +49,38 @@ class _AdmobBannerAdState extends State { return ''; } + bool _requested = false; + @override - void initState() { - super.initState(); - _initAndLoadAd(); + void didChangeDependencies() { + super.didChangeDependencies(); + + // adaptive 크기를 구하려면 화면 폭이 필요해 여기서 한 번만 시작한다 + if (_requested) return; + _requested = true; + _initAndLoadAd(MediaQuery.sizeOf(context).width.truncate()); } - Future _initAndLoadAd() async { + Future _initAndLoadAd(int width) async { final isTestFlight = await AppDistribution.isTestFlight(); _useTestAds = kDebugMode || isTestFlight; if (!mounted) return; - _loadAd(); + + final size = + await AdSize.getCurrentOrientationAnchoredAdaptiveBannerAdSize(width); + if (!mounted) return; + + // 기기가 adaptive 크기를 못 주면 표준 배너로 떨어진다 + _loadAd(size ?? AdSize.banner); } - void _loadAd() { + void _loadAd(AdSize size) { final adUnitId = _adUnitId; if (adUnitId.isEmpty) return; final ad = BannerAd( adUnitId: adUnitId, - size: AdSize.banner, + size: size, request: const AdRequest(), listener: BannerAdListener( onAdLoaded: (_) { From 6103977568aedcaa748bacf3add7bcb0acdd3dc2 Mon Sep 17 00:00:00 2001 From: rdyjun Date: Sat, 5 Sep 2026 14:11:00 +0900 Subject: [PATCH 03/40] =?UTF-8?q?fix:=20=EC=9D=B8=EC=82=AC=EB=A7=90?= =?UTF-8?q?=EC=9D=B4=20=EC=82=AC=EC=8B=A4=EA=B3=BC=20=EB=8B=A4=EB=A5=B4?= =?UTF-8?q?=EB=8D=98=20=EB=AC=B8=EC=A0=9C=EC=99=80=20=EB=8F=99=EC=9E=91?= =?UTF-8?q?=ED=95=98=EC=A7=80=20=EC=95=8A=EB=8D=98=20=EC=A1=B0=EA=B1=B4?= =?UTF-8?q?=EB=AC=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 홈 공지는 서버에서 세 건으로 잘려 오고(searchHomeNotices 의 limit(3)) 읽음 여부도 알 수 없어, 그 길이를 "새 공지 N개" 로 쓰면 매일 3개가 뜬다. 공지 수를 빼고 수업만 센다. 안 읽은 공지를 셀 수 있게 되면 그때 넘긴다. gray7 == background 로 빈 상태를 흐리게 하려 했으나 gray7 을 넘기는 곳이 없어 항상 검정이었다. isMuted 로 명시한다. 그 밖에 - 바로가기 배경색을 ColorStyles.mintBg / amberBg 로 바꾼다 - prefer_const_constructors 에 걸리던 TextStyle 세 곳에 const 를 붙인다 --- lib/presentation/home/home_page_screen.dart | 6 ++---- lib/presentation/home/widgets/home_greeting.dart | 8 ++++++-- .../home/widgets/home_quick_links.dart | 4 ++-- .../home/widgets/home_today_card.dart | 16 ++++++++++------ 4 files changed, 20 insertions(+), 14 deletions(-) diff --git a/lib/presentation/home/home_page_screen.dart b/lib/presentation/home/home_page_screen.dart index e705a230..3f2afba7 100644 --- a/lib/presentation/home/home_page_screen.dart +++ b/lib/presentation/home/home_page_screen.dart @@ -86,10 +86,8 @@ class HomePageScreen extends HookConsumerWidget { child: ListView( padding: EdgeInsets.zero, children: [ - HomeGreeting( - classCount: homeEntity.timeTable.length, - noticeCount: homeEntity.notices.length, - ), + // 공지는 세 건으로 잘려 오고 읽음 여부도 몰라 아직 세지 않는다 + HomeGreeting(classCount: homeEntity.timeTable.length), HomeTodayCard(timeTable: homeEntity.timeTable), HomeNoticeList(notices: homeEntity.notices), const Padding( diff --git a/lib/presentation/home/widgets/home_greeting.dart b/lib/presentation/home/widgets/home_greeting.dart index bf3013a8..15fd4a0f 100644 --- a/lib/presentation/home/widgets/home_greeting.dart +++ b/lib/presentation/home/widgets/home_greeting.dart @@ -7,6 +7,10 @@ import 'package:intl/intl.dart'; /// /// 0 인 항목은 문장에서 뺀다. 둘 다 없으면 다른 문장으로 바꾼다 — /// "수업 0개, 공지 0개" 는 읽는 사람에게 아무것도 알려주지 않는다. +/// +/// [noticeCount] 는 아직 채우지 않는다. 홈 응답의 공지는 서버에서 세 건으로 잘려 오고 +/// (`searchHomeNotices` 의 limit(3)) 읽음 여부도 알 수 없어, 그 길이를 그대로 쓰면 +/// 매일 "새 공지 3개" 가 뜬다. 안 읽은 공지를 셀 수 있게 되면 그때 넘긴다. class HomeGreeting extends StatelessWidget { final int classCount; final int noticeCount; @@ -14,7 +18,7 @@ class HomeGreeting extends StatelessWidget { const HomeGreeting({ super.key, required this.classCount, - required this.noticeCount, + this.noticeCount = 0, }); @override @@ -72,7 +76,7 @@ class HomeGreeting extends StatelessWidget { TextSpan(text: label), TextSpan( text: '$count개', - style: TextStyle(color: ColorStyles.primary100), + style: const TextStyle(color: ColorStyles.primary100), ), ], ); diff --git a/lib/presentation/home/widgets/home_quick_links.dart b/lib/presentation/home/widgets/home_quick_links.dart index 1168e59b..0d4dfc2c 100644 --- a/lib/presentation/home/widgets/home_quick_links.dart +++ b/lib/presentation/home/widgets/home_quick_links.dart @@ -14,8 +14,8 @@ class HomeQuickLinks extends StatelessWidget { Widget build(BuildContext context) { final items = <_QuickItem>[ _QuickItem('🍽️', '맛집', ColorStyles.primary5, RoutePaths.restaurants), - _QuickItem('📚', '도서관', const Color(0xFFE4F7EE), RoutePaths.libraryWebView), - _QuickItem('💬', '챗봇', const Color(0xFFFFF6E0), RoutePaths.chatbot), + _QuickItem('📚', '도서관', ColorStyles.mintBg, RoutePaths.libraryWebView), + _QuickItem('💬', '챗봇', ColorStyles.amberBg, RoutePaths.chatbot), _QuickItem('🗓️', '학사일정', ColorStyles.gray1, RoutePaths.schedule), ]; diff --git a/lib/presentation/home/widgets/home_today_card.dart b/lib/presentation/home/widgets/home_today_card.dart index e200d6f4..07b49eed 100644 --- a/lib/presentation/home/widgets/home_today_card.dart +++ b/lib/presentation/home/widgets/home_today_card.dart @@ -46,6 +46,7 @@ class HomeTodayCard extends HookConsumerWidget { emoji: '📘', background: ColorStyles.primary5, title: '오늘은 수업이 없어요', + isMuted: true, ) : _classRow(timeTable[index]), onTapItem: () => context.push(RoutePaths.timetable), @@ -63,10 +64,11 @@ class HomeTodayCard extends HookConsumerWidget { emoji: '🍚', background: ColorStyles.gray2, title: '이번 주 학식 정보가 없어요', + isMuted: true, ) : _TodayRow( emoji: '🍚', - background: const Color(0xFFFFF6E0), + background: ColorStyles.amberBg, title: meals[index].koreanMenu, ), ), @@ -109,11 +111,11 @@ class _MealHeader extends StatelessWidget { if (label != null) ...[ TextSpan( text: ' · ', - style: TextStyle(color: ColorStyles.gray4), + style: const TextStyle(color: ColorStyles.gray4), ), TextSpan( text: label, - style: TextStyle(color: ColorStyles.gray6), + style: const TextStyle(color: ColorStyles.gray6), ), ], ], @@ -141,12 +143,16 @@ class _TodayRow extends StatelessWidget { final String? description; final bool showChevron; + /// 비어 있음을 알리는 줄은 흐리게 둔다 + final bool isMuted; + const _TodayRow({ required this.emoji, required this.background, required this.title, this.description, this.showChevron = false, + this.isMuted = false, }); @override @@ -175,9 +181,7 @@ class _TodayRow extends StatelessWidget { maxLines: 2, overflow: TextOverflow.ellipsis, style: TextStyles.normalTextBold.copyWith( - color: ColorStyles.gray7 == background - ? ColorStyles.gray4 - : ColorStyles.black, + color: isMuted ? ColorStyles.gray4 : ColorStyles.black, height: 1.4, ), ), From 1a4080d11ab241749832cba27ce840026cea0b52 Mon Sep 17 00:00:00 2001 From: rdyjun Date: Sat, 5 Sep 2026 14:51:25 +0900 Subject: [PATCH 04/40] fix: import Restaurant model for top restaurants provider --- lib/providers/restaurants_providers.dart | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/providers/restaurants_providers.dart b/lib/providers/restaurants_providers.dart index f740fda9..372069b4 100644 --- a/lib/providers/restaurants_providers.dart +++ b/lib/providers/restaurants_providers.dart @@ -1,6 +1,7 @@ import 'package:dongsoop/data/restaurants/data_source/restaurants_data_source.dart'; import 'package:dongsoop/data/restaurants/data_source/restaurants_data_source_impl.dart'; import 'package:dongsoop/data/restaurants/repository/restaurants_repository_impl.dart'; +import 'package:dongsoop/domain/restaurants/model/restaurant.dart'; import 'package:dongsoop/domain/restaurants/repository/restaurants_repository.dart'; import 'package:dongsoop/domain/restaurants/use_case/check_restaurants_duplication_use_case.dart'; import 'package:dongsoop/domain/restaurants/use_case/create_restaurants_use_case.dart'; From 5b622464e070130fb27fbd407719a8c0464ced51 Mon Sep 17 00:00:00 2001 From: rdyjun Date: Sat, 5 Sep 2026 14:51:41 +0900 Subject: [PATCH 05/40] fix: encode restaurant webview URL query --- lib/presentation/campus/widgets/campus_restaurant_list.dart | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/lib/presentation/campus/widgets/campus_restaurant_list.dart b/lib/presentation/campus/widgets/campus_restaurant_list.dart index 3b6a35c6..0363d7d3 100644 --- a/lib/presentation/campus/widgets/campus_restaurant_list.dart +++ b/lib/presentation/campus/widgets/campus_restaurant_list.dart @@ -74,7 +74,11 @@ class _RestaurantCard extends StatelessWidget { onTap: () { final url = restaurant.placeUrl; if (url == null || url.isEmpty) return; - context.push('/restaurantWebView?url=$url'); + final location = Uri( + path: '/restaurantWebView', + queryParameters: {'url': url}, + ).toString(); + context.push(location); }, child: SizedBox( width: CampusRestaurantList._cardWidth, From d8b48cbed90fbdf75c397844384a7a5572b64f0b Mon Sep 17 00:00:00 2001 From: rdyjun Date: Sat, 5 Sep 2026 14:51:57 +0900 Subject: [PATCH 06/40] fix: make swipe deck initial page and layout robust --- lib/presentation/home/widgets/swipe_deck.dart | 87 ++++++++++++++----- 1 file changed, 64 insertions(+), 23 deletions(-) diff --git a/lib/presentation/home/widgets/swipe_deck.dart b/lib/presentation/home/widgets/swipe_deck.dart index e58b2cc3..bcce5702 100644 --- a/lib/presentation/home/widgets/swipe_deck.dart +++ b/lib/presentation/home/widgets/swipe_deck.dart @@ -1,16 +1,17 @@ import 'package:dongsoop/ui/color_styles.dart'; import 'package:flutter/material.dart'; -/// 좌우로 넘기는 카드 한 줄과 점 인디케이터. +/// 좌우로 넘기는 카드 한 줄과 인디케이터. /// /// 넘길 게 하나뿐이어도 끌리도록 [BouncingScrollPhysics] 를 쓴다. -/// 끝에서 고무줄처럼 딸려왔다 돌아오면 "여기는 넘기는 곳" 이라는 게 전해지고, -/// 넘길 게 없다는 것도 같이 알려준다. 안내 문구를 따로 두지 않는 이유다. +/// 항목이 많을 때는 점을 전부 늘어놓지 않고 현재 위치만 숫자로 보여준다. class SwipeDeck extends StatefulWidget { final int itemCount; final IndexedWidgetBuilder itemBuilder; final ValueChanged? onPageChanged; final VoidCallback? onTapItem; + final int initialPage; + final double height; const SwipeDeck({ super.key, @@ -18,6 +19,8 @@ class SwipeDeck extends StatefulWidget { required this.itemBuilder, this.onPageChanged, this.onTapItem, + this.initialPage = 0, + this.height = 68, }); @override @@ -25,8 +28,36 @@ class SwipeDeck extends StatefulWidget { } class _SwipeDeckState extends State { - late final PageController _controller = PageController(); - int _index = 0; + late final PageController _controller; + late int _index; + + @override + void initState() { + super.initState(); + _index = _normalizePage(widget.initialPage); + _controller = PageController(initialPage: _index); + } + + @override + void didUpdateWidget(covariant SwipeDeck oldWidget) { + super.didUpdateWidget(oldWidget); + + final target = _normalizePage(widget.initialPage); + final currentIsInvalid = _index >= widget.itemCount; + + if (target == _index && !currentIsInvalid) return; + + _index = target; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted || !_controller.hasClients) return; + _controller.jumpToPage(target); + }); + } + + int _normalizePage(int page) { + if (widget.itemCount <= 0) return 0; + return page.clamp(0, widget.itemCount - 1); + } @override void dispose() { @@ -39,7 +70,7 @@ class _SwipeDeckState extends State { return Column( children: [ SizedBox( - height: 52, + height: widget.height, child: PageView.builder( controller: _controller, physics: const BouncingScrollPhysics(), @@ -65,23 +96,33 @@ class _SwipeDeckState extends State { ), ), if (widget.itemCount > 1) ...[ - const SizedBox(height: 12), - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: List.generate(widget.itemCount, (i) { - final isCurrent = i == _index; - return AnimatedContainer( - duration: const Duration(milliseconds: 200), - margin: const EdgeInsets.symmetric(horizontal: 2.5), - width: isCurrent ? 14 : 5, - height: 5, - decoration: BoxDecoration( - color: isCurrent ? ColorStyles.gray5 : ColorStyles.gray2, - borderRadius: BorderRadius.circular(3), - ), - ); - }), - ), + const SizedBox(height: 10), + if (widget.itemCount <= 7) + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: List.generate(widget.itemCount, (i) { + final isCurrent = i == _index; + return AnimatedContainer( + duration: const Duration(milliseconds: 200), + margin: const EdgeInsets.symmetric(horizontal: 2.5), + width: isCurrent ? 14 : 5, + height: 5, + decoration: BoxDecoration( + color: isCurrent ? ColorStyles.gray5 : ColorStyles.gray2, + borderRadius: BorderRadius.circular(3), + ), + ); + }), + ) + else + Text( + '${_index + 1} / ${widget.itemCount}', + style: const TextStyle( + fontSize: 11, + color: ColorStyles.gray5, + height: 1.2, + ), + ), ], ], ); From 96b4397b1cfc78275d24a0f54a41000b2a1a5616 Mon Sep 17 00:00:00 2001 From: rdyjun Date: Sat, 5 Sep 2026 14:52:05 +0900 Subject: [PATCH 07/40] fix: open cafeteria deck on today's meal --- .../home/widgets/home_today_card.dart | 25 ++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/lib/presentation/home/widgets/home_today_card.dart b/lib/presentation/home/widgets/home_today_card.dart index 07b49eed..5769efd6 100644 --- a/lib/presentation/home/widgets/home_today_card.dart +++ b/lib/presentation/home/widgets/home_today_card.dart @@ -22,11 +22,17 @@ class HomeTodayCard extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final mealIndex = useState(0); final meals = ref.watch(cafeteriaViewModelProvider).maybeWhen( data: (data) => data.weekMeals, orElse: () => const [], ); + final todayMealIndex = _findTodayMealIndex(meals); + final mealIndex = useState(todayMealIndex); + + useEffect(() { + mealIndex.value = todayMealIndex; + return null; + }, [meals]); return Padding( padding: const EdgeInsets.fromLTRB(20, 22, 20, 0), @@ -58,6 +64,7 @@ class HomeTodayCard extends HookConsumerWidget { _MealHeader(meals: meals, index: mealIndex.value), SwipeDeck( itemCount: meals.isEmpty ? 1 : meals.length, + initialPage: mealIndex.value, onPageChanged: (index) => mealIndex.value = index, itemBuilder: (context, index) => meals.isEmpty ? const _TodayRow( @@ -78,6 +85,22 @@ class HomeTodayCard extends HookConsumerWidget { ); } + int _findTodayMealIndex(List meals) { + if (meals.isEmpty) return 0; + + final now = DateTime.now(); + final index = meals.indexWhere((meal) { + final date = DateTime.tryParse(meal.date); + if (date == null) return false; + + return date.year == now.year && + date.month == now.month && + date.day == now.day; + }); + + return index >= 0 ? index : 0; + } + Widget _classRow(Slot slot) { return _TodayRow( emoji: '📘', From aff488e586c7dbf235594a2ff0657e6eae49758b Mon Sep 17 00:00:00 2001 From: rdyjun Date: Sat, 5 Sep 2026 14:52:36 +0900 Subject: [PATCH 08/40] ci: fail fast on analyzer errors and refresh cache action --- .github/workflows/flutter-ios.yml | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/.github/workflows/flutter-ios.yml b/.github/workflows/flutter-ios.yml index 62d3af93..797ca3d2 100644 --- a/.github/workflows/flutter-ios.yml +++ b/.github/workflows/flutter-ios.yml @@ -18,11 +18,11 @@ jobs: # Flutter SDK (subosito) 이전에 pub 캐시 복원 - name: Cache Flutter pub packages - uses: actions/cache@v3 + uses: actions/cache@v4 with: path: ~/.pub-cache - key: pubcache-${{ runner.os }}-stable-3.27.1-${{ hashFiles('pubspec.lock') }} - restore-keys: pubcache-${{ runner.os }}-stable-3.27.1- + key: pubcache-${{ runner.os }}-stable-3.35.4-${{ hashFiles('pubspec.lock') }} + restore-keys: pubcache-${{ runner.os }}-stable-3.35.4- - name: Setup Flutter uses: subosito/flutter-action@v2 @@ -58,7 +58,7 @@ jobs: # iOS Pods 캐시 복원 - name: Cache iOS Pods - uses: actions/cache@v3 + uses: actions/cache@v4 with: path: ios/Pods key: iospods-${{ runner.os }}-${{ hashFiles('ios/Podfile.lock') }} @@ -69,10 +69,9 @@ jobs: flutter pub get cd ios && pod install --repo-update - # 코드 분석 수행 (경고는 허용, 에러만 실패 처리) + # 코드 분석 수행 (경고는 허용, analyzer error는 즉시 실패) - name: Analyze code - run: flutter analyze --no-fatal-warnings || echo "Analysis completed with warnings" - continue-on-error: false + run: flutter analyze --no-fatal-warnings # 무료 버전 - Apple Developer 계정 없이 빌드만 - name: Build iOS (no codesign) From 6a964dd1acf3301c8248241be907bbc87c3026c2 Mon Sep 17 00:00:00 2001 From: rdyjun Date: Sat, 5 Sep 2026 14:52:51 +0900 Subject: [PATCH 09/40] fix: use inline adaptive banner in home scroll --- .../components/admob_banner_ad.dart | 110 +++++++++++------- 1 file changed, 70 insertions(+), 40 deletions(-) diff --git a/lib/core/presentation/components/admob_banner_ad.dart b/lib/core/presentation/components/admob_banner_ad.dart index 303bbb34..b456f87c 100644 --- a/lib/core/presentation/components/admob_banner_ad.dart +++ b/lib/core/presentation/components/admob_banner_ad.dart @@ -1,4 +1,5 @@ import 'dart:io'; + import 'package:dongsoop/core/environment/app_distribution.dart'; import 'package:dongsoop/ui/color_styles.dart'; import 'package:flutter/foundation.dart'; @@ -7,17 +8,10 @@ import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:google_mobile_ads/google_mobile_ads.dart'; import 'package:logger/logger.dart'; -/// 가로형 배너 광고. -/// -/// 홈에서 쓰던 세로 250 네이티브 광고는 공지와 바로가기 사이를 크게 갈랐다. -/// 목록 흐름을 끊지 않도록 화면 폭에 맞춘 배너로 바꾼다. -/// -/// 고정 320x50(`AdSize.banner`) 대신 **anchored adaptive** 를 쓴다. 구글이 권하는 -/// 방식이고, 폭이 390~430 인 요즘 기기에서 고정 320 을 쓰면 양옆에 빈 자리가 남는다. -/// 기기 폭을 넘기면 그에 맞는 높이를 돌려준다. +/// 홈의 스크롤 콘텐츠 안에 배치되는 인라인 적응형 배너 광고. /// -/// 광고가 아직 안 붙었거나 실패하면 아무것도 그리지 않는다 — 빈 회색 칸이 남으면 -/// 로딩이 끝나지 않은 것처럼 보인다. +/// 광고가 아직 로드되지 않았거나 실패하면 빈 공간을 남기지 않는다. +/// 화면 폭/방향이 바뀌면 해당 크기에 맞춰 광고를 다시 요청한다. class AdmobBannerAd extends StatefulWidget { const AdmobBannerAd({super.key}); @@ -27,21 +21,27 @@ class AdmobBannerAd extends StatefulWidget { class _AdmobBannerAdState extends State { BannerAd? _bannerAd; + AdSize? _loadedSize; bool _isLoaded = false; bool _useTestAds = kDebugMode; + int? _requestedWidth; + Orientation? _requestedOrientation; + int _requestGeneration = 0; final Logger _logger = Logger(); static const String _androidTestAdUnitId = - 'ca-app-pub-3940256099942544/6300978111'; + 'ca-app-pub-3940256099942544/9214589741'; static const String _iosTestAdUnitId = - 'ca-app-pub-3940256099942544/2934735716'; + 'ca-app-pub-3940256099942544/2435281174'; String get _adUnitId { if (Platform.isAndroid) { if (_useTestAds) return _androidTestAdUnitId; return dotenv.maybeGet('ADMOB_ANDROID_BANNER_ID') ?? _androidTestAdUnitId; - } else if (Platform.isIOS) { + } + + if (Platform.isIOS) { if (_useTestAds) return _iosTestAdUnitId; return dotenv.maybeGet('ADMOB_IOS_BANNER_ID') ?? _iosTestAdUnitId; } @@ -49,62 +49,90 @@ class _AdmobBannerAdState extends State { return ''; } - bool _requested = false; - @override void didChangeDependencies() { super.didChangeDependencies(); - // adaptive 크기를 구하려면 화면 폭이 필요해 여기서 한 번만 시작한다 - if (_requested) return; - _requested = true; - _initAndLoadAd(MediaQuery.sizeOf(context).width.truncate()); + final mediaQuery = MediaQuery.of(context); + final width = mediaQuery.size.width.truncate(); + final orientation = mediaQuery.orientation; + + if (_requestedWidth == width && _requestedOrientation == orientation) { + return; + } + + _requestedWidth = width; + _requestedOrientation = orientation; + _loadInlineAdaptiveAd(width); } - Future _initAndLoadAd(int width) async { + Future _loadInlineAdaptiveAd(int width) async { + final generation = ++_requestGeneration; final isTestFlight = await AppDistribution.isTestFlight(); + + if (!mounted || generation != _requestGeneration) return; + _useTestAds = kDebugMode || isTestFlight; - if (!mounted) return; - final size = - await AdSize.getCurrentOrientationAnchoredAdaptiveBannerAdSize(width); - if (!mounted) return; + final previousAd = _bannerAd; + _bannerAd = null; + _loadedSize = null; + _isLoaded = false; + await previousAd?.dispose(); - // 기기가 adaptive 크기를 못 주면 표준 배너로 떨어진다 - _loadAd(size ?? AdSize.banner); - } + if (!mounted || generation != _requestGeneration) return; - void _loadAd(AdSize size) { final adUnitId = _adUnitId; if (adUnitId.isEmpty) return; - final ad = BannerAd( + // 홈은 ListView 안에 있으므로 anchored가 아니라 inline adaptive를 사용한다. + final requestSize = AdSize.getInlineAdaptiveBannerAdSize(width, 100); + + late final BannerAd ad; + ad = BannerAd( adUnitId: adUnitId, - size: size, + size: requestSize, request: const AdRequest(), listener: BannerAdListener( - onAdLoaded: (_) { - if (!mounted) return; - setState(() => _isLoaded = true); + onAdLoaded: (_) async { + if (!mounted || generation != _requestGeneration) { + await ad.dispose(); + return; + } + + final platformSize = await ad.getPlatformAdSize(); + if (!mounted || generation != _requestGeneration) { + await ad.dispose(); + return; + } + + setState(() { + _bannerAd = ad; + _loadedSize = platformSize ?? ad.size; + _isLoaded = true; + }); }, - onAdFailedToLoad: (ad, error) { + onAdFailedToLoad: (failedAd, error) { _logger.d('AdMob banner failed: $error'); - ad.dispose(); - if (!mounted) return; + failedAd.dispose(); + + if (!mounted || generation != _requestGeneration) return; + setState(() { _bannerAd = null; + _loadedSize = null; _isLoaded = false; }); }, ), ); - _bannerAd = ad; ad.load(); } @override void dispose() { + _requestGeneration++; _bannerAd?.dispose(); super.dispose(); } @@ -112,15 +140,17 @@ class _AdmobBannerAdState extends State { @override Widget build(BuildContext context) { final ad = _bannerAd; - if (!_isLoaded || ad == null) { + final size = _loadedSize; + + if (!_isLoaded || ad == null || size == null) { return const SizedBox.shrink(); } return Container( alignment: Alignment.center, color: ColorStyles.white, - width: ad.size.width.toDouble(), - height: ad.size.height.toDouble(), + width: size.width.toDouble(), + height: size.height.toDouble(), child: AdWidget(ad: ad), ); } From 69456bfb12e4a2a6e929b75ff4c59a522f8bcdba Mon Sep 17 00:00:00 2001 From: rdyjun Date: Sat, 5 Sep 2026 14:53:19 +0900 Subject: [PATCH 10/40] refactor: remove unused notice count greeting path --- lib/presentation/home/widgets/home_greeting.dart | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/lib/presentation/home/widgets/home_greeting.dart b/lib/presentation/home/widgets/home_greeting.dart index 15fd4a0f..3bed32fb 100644 --- a/lib/presentation/home/widgets/home_greeting.dart +++ b/lib/presentation/home/widgets/home_greeting.dart @@ -7,18 +7,12 @@ import 'package:intl/intl.dart'; /// /// 0 인 항목은 문장에서 뺀다. 둘 다 없으면 다른 문장으로 바꾼다 — /// "수업 0개, 공지 0개" 는 읽는 사람에게 아무것도 알려주지 않는다. -/// -/// [noticeCount] 는 아직 채우지 않는다. 홈 응답의 공지는 서버에서 세 건으로 잘려 오고 -/// (`searchHomeNotices` 의 limit(3)) 읽음 여부도 알 수 없어, 그 길이를 그대로 쓰면 -/// 매일 "새 공지 3개" 가 뜬다. 안 읽은 공지를 셀 수 있게 되면 그때 넘긴다. class HomeGreeting extends StatelessWidget { final int classCount; - final int noticeCount; const HomeGreeting({ super.key, required this.classCount, - this.noticeCount = 0, }); @override @@ -51,10 +45,6 @@ class HomeGreeting extends StatelessWidget { if (classCount > 0) { parts.add(_countPhrase('오늘 수업 ', classCount)); } - if (noticeCount > 0) { - parts.add(_countPhrase('새 공지 ', noticeCount)); - } - if (parts.isEmpty) { return const TextSpan(text: '오늘은 일정이 없어요'); } From b8eface760f3acbc4227cb44d4ba0bcf96a48c27 Mon Sep 17 00:00:00 2001 From: rdyjun Date: Sat, 5 Sep 2026 15:04:16 +0900 Subject: [PATCH 11/40] ci: fail analyzer only on errors --- .github/workflows/flutter-ios.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/flutter-ios.yml b/.github/workflows/flutter-ios.yml index 797ca3d2..77c89a3f 100644 --- a/.github/workflows/flutter-ios.yml +++ b/.github/workflows/flutter-ios.yml @@ -71,7 +71,7 @@ jobs: # 코드 분석 수행 (경고는 허용, analyzer error는 즉시 실패) - name: Analyze code - run: flutter analyze --no-fatal-warnings + run: flutter analyze --no-fatal-warnings --no-fatal-infos # 무료 버전 - Apple Developer 계정 없이 빌드만 - name: Build iOS (no codesign) From fa6d9b6e4c776ce66bd982d06b67e1781a238eb1 Mon Sep 17 00:00:00 2001 From: rdyjun Date: Sat, 5 Sep 2026 15:04:19 +0900 Subject: [PATCH 12/40] chore: add missing flutter_lints dependency --- pubspec.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/pubspec.yaml b/pubspec.yaml index cf95db5a..244f72f8 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -78,6 +78,7 @@ dependencies: dev_dependencies: flutter_test: sdk: flutter + flutter_lints: ^5.0.0 build_runner: ^2.4.15 freezed: ^3.0.6 json_serializable: ^6.9.5 From d05592ba6e6e01d5ecb64e39bc3ffe46a6052221 Mon Sep 17 00:00:00 2001 From: rdyjun Date: Sat, 5 Sep 2026 15:04:28 +0900 Subject: [PATCH 13/40] fix: preserve swipe position across parent rebuilds --- lib/presentation/home/widgets/swipe_deck.dart | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/lib/presentation/home/widgets/swipe_deck.dart b/lib/presentation/home/widgets/swipe_deck.dart index bcce5702..03932fc9 100644 --- a/lib/presentation/home/widgets/swipe_deck.dart +++ b/lib/presentation/home/widgets/swipe_deck.dart @@ -42,9 +42,15 @@ class _SwipeDeckState extends State { void didUpdateWidget(covariant SwipeDeck oldWidget) { super.didUpdateWidget(oldWidget); - final target = _normalizePage(widget.initialPage); + final initialPageChanged = widget.initialPage != oldWidget.initialPage; final currentIsInvalid = _index >= widget.itemCount; + if (!initialPageChanged && !currentIsInvalid) return; + + final target = _normalizePage( + initialPageChanged ? widget.initialPage : _index, + ); + if (target == _index && !currentIsInvalid) return; _index = target; From de0187b7e68ddb8d611ddf00ec393018d4611489 Mon Sep 17 00:00:00 2001 From: rdyjun Date: Sat, 5 Sep 2026 15:04:31 +0900 Subject: [PATCH 14/40] fix: dispose in-flight banner ads safely --- lib/core/presentation/components/admob_banner_ad.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/core/presentation/components/admob_banner_ad.dart b/lib/core/presentation/components/admob_banner_ad.dart index b456f87c..9ad30def 100644 --- a/lib/core/presentation/components/admob_banner_ad.dart +++ b/lib/core/presentation/components/admob_banner_ad.dart @@ -107,7 +107,6 @@ class _AdmobBannerAdState extends State { } setState(() { - _bannerAd = ad; _loadedSize = platformSize ?? ad.size; _isLoaded = true; }); @@ -127,6 +126,7 @@ class _AdmobBannerAdState extends State { ), ); + _bannerAd = ad; ad.load(); } From ba9f498afcec59ebafe331442584081d32ce46c1 Mon Sep 17 00:00:00 2001 From: rdyjun Date: Sat, 5 Sep 2026 18:21:06 +0900 Subject: [PATCH 15/40] refactor: sync reusable campus architecture --- .../components/section_header.dart | 95 +++++++++++++++++++ 1 file changed, 95 insertions(+) create mode 100644 lib/core/presentation/components/section_header.dart diff --git a/lib/core/presentation/components/section_header.dart b/lib/core/presentation/components/section_header.dart new file mode 100644 index 00000000..b833d8a6 --- /dev/null +++ b/lib/core/presentation/components/section_header.dart @@ -0,0 +1,95 @@ +import 'package:dongsoop/ui/color_styles.dart'; +import 'package:dongsoop/ui/text_styles.dart'; +import 'package:flutter/material.dart'; + +/// 여러 화면에서 재사용하는 섹션 헤더. +/// +/// 제목 옆 보조 정보와 우측 액션은 [Widget] 으로 받아, +/// 날짜·카운트·버튼 등 새로운 표현이 추가되어도 이 컴포넌트를 수정하지 않는다. +class SectionHeader extends StatelessWidget { + final String title; + final Widget? suffix; + final Widget? action; + + const SectionHeader({ + super.key, + required this.title, + this.suffix, + this.action, + }); + + @override + Widget build(BuildContext context) { + return Row( + children: [ + Expanded( + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Flexible( + child: Text( + title, + overflow: TextOverflow.ellipsis, + style: TextStyles.largeTextBold.copyWith( + color: ColorStyles.black, + ), + ), + ), + if (suffix != null) ...[ + const SizedBox(width: 4), + Flexible(child: suffix!), + ], + ], + ), + ), + if (action != null) ...[ + const SizedBox(width: 12), + action!, + ], + ], + ); + } +} + +/// 섹션 헤더의 표준 텍스트 액션. +class SectionHeaderAction extends StatelessWidget { + final String label; + final VoidCallback onTap; + + const SectionHeaderAction({ + super.key, + required this.label, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + return Material( + color: Colors.transparent, + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(8), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 6), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + label, + style: TextStyles.smallTextRegular.copyWith( + color: ColorStyles.gray5, + ), + ), + const SizedBox(width: 2), + const Icon( + Icons.arrow_forward_ios, + size: 12, + color: ColorStyles.gray5, + ), + ], + ), + ), + ), + ); + } +} From 1898fb37179b6219034c0905d2ea4c2df1911676 Mon Sep 17 00:00:00 2001 From: rdyjun Date: Sat, 5 Sep 2026 18:21:10 +0900 Subject: [PATCH 16/40] refactor: sync reusable campus architecture --- .../providers/top_restaurants_provider.dart | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 lib/presentation/campus/providers/top_restaurants_provider.dart diff --git a/lib/presentation/campus/providers/top_restaurants_provider.dart b/lib/presentation/campus/providers/top_restaurants_provider.dart new file mode 100644 index 00000000..21a409ca --- /dev/null +++ b/lib/presentation/campus/providers/top_restaurants_provider.dart @@ -0,0 +1,22 @@ +import 'package:dongsoop/domain/restaurants/model/restaurant.dart'; +import 'package:dongsoop/providers/auth_providers.dart'; +import 'package:dongsoop/providers/restaurants_providers.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +/// 캠퍼스 탭의 "오늘 뭐 먹지" 목록. +/// +/// 공용 restaurants provider는 data source/repository/use case 조립만 담당하고, +/// 캠퍼스 화면에 필요한 "상위 6개" 정책은 feature provider에 둔다. +final topRestaurantsProvider = + FutureProvider.autoDispose>((ref) async { + final isLogin = ref.watch(userSessionProvider) != null; + final useCase = ref.watch(getRestaurantsUseCaseProvider); + + final restaurants = await useCase.execute( + isLogin: isLogin, + page: 0, + size: 6, + ); + + return restaurants ?? const []; +}); From f6dda9848295c8f3dd738ee973360117af872618 Mon Sep 17 00:00:00 2001 From: rdyjun Date: Sat, 5 Sep 2026 18:21:17 +0900 Subject: [PATCH 17/40] refactor: sync latest campus base architecture --- lib/providers/restaurants_providers.dart | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/lib/providers/restaurants_providers.dart b/lib/providers/restaurants_providers.dart index 372069b4..c145a7d1 100644 --- a/lib/providers/restaurants_providers.dart +++ b/lib/providers/restaurants_providers.dart @@ -1,7 +1,6 @@ import 'package:dongsoop/data/restaurants/data_source/restaurants_data_source.dart'; import 'package:dongsoop/data/restaurants/data_source/restaurants_data_source_impl.dart'; import 'package:dongsoop/data/restaurants/repository/restaurants_repository_impl.dart'; -import 'package:dongsoop/domain/restaurants/model/restaurant.dart'; import 'package:dongsoop/domain/restaurants/repository/restaurants_repository.dart'; import 'package:dongsoop/domain/restaurants/use_case/check_restaurants_duplication_use_case.dart'; import 'package:dongsoop/domain/restaurants/use_case/create_restaurants_use_case.dart'; @@ -99,16 +98,3 @@ StateNotifierProvider.autoDispose>((ref) async { - final isLogin = ref.watch(userSessionProvider) != null; - final useCase = ref.watch(getRestaurantsUseCaseProvider); - - final restaurants = await useCase.execute(isLogin: isLogin, page: 0, size: 6); - return restaurants ?? const []; -}); From 72166c5cdac86c0e519477ffe745c50430cfb418 Mon Sep 17 00:00:00 2001 From: rdyjun Date: Sat, 5 Sep 2026 18:21:20 +0900 Subject: [PATCH 18/40] refactor: sync latest campus base architecture --- lib/presentation/campus/widgets/campus_restaurant_list.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/presentation/campus/widgets/campus_restaurant_list.dart b/lib/presentation/campus/widgets/campus_restaurant_list.dart index 0363d7d3..52ad26de 100644 --- a/lib/presentation/campus/widgets/campus_restaurant_list.dart +++ b/lib/presentation/campus/widgets/campus_restaurant_list.dart @@ -1,6 +1,6 @@ import 'package:dongsoop/domain/restaurants/model/restaurant.dart'; import 'package:dongsoop/presentation/campus/restaurant_category_style.dart'; -import 'package:dongsoop/providers/restaurants_providers.dart'; +import 'package:dongsoop/presentation/campus/providers/top_restaurants_provider.dart'; import 'package:dongsoop/ui/color_styles.dart'; import 'package:dongsoop/ui/text_styles.dart'; import 'package:flutter/material.dart'; From 3b25ca3b4c954c421cbd019063e38ecad46a060c Mon Sep 17 00:00:00 2001 From: rdyjun Date: Sat, 5 Sep 2026 18:21:24 +0900 Subject: [PATCH 19/40] refactor: sync latest campus base architecture --- .../campus/widgets/campus_section.dart | 76 +++---------------- 1 file changed, 11 insertions(+), 65 deletions(-) diff --git a/lib/presentation/campus/widgets/campus_section.dart b/lib/presentation/campus/widgets/campus_section.dart index c317e44d..724e2237 100644 --- a/lib/presentation/campus/widgets/campus_section.dart +++ b/lib/presentation/campus/widgets/campus_section.dart @@ -1,26 +1,22 @@ -import 'package:dongsoop/ui/color_styles.dart'; -import 'package:dongsoop/ui/text_styles.dart'; +import 'package:dongsoop/core/presentation/components/section_header.dart'; import 'package:flutter/material.dart'; -import 'package:intl/intl.dart'; /// 캠퍼스 탭의 한 구획. /// -/// 제목 오른쪽에는 `전체 ›` 같은 이동 링크나 날짜 중 하나만 붙는다. -/// 날짜는 제목 옆 중점으로 붙여 홈의 학식 표기와 같은 꼴을 유지한다. +/// 구획 레이아웃만 담당한다. 제목 옆/오른쪽 표현은 Widget으로 열어 두어 +/// 새로운 배지나 액션이 추가되어도 이 컴포넌트를 수정하지 않는다. class CampusSection extends StatelessWidget { final String title; final Widget child; - final String? trailingLabel; - final VoidCallback? onTapTrailing; - final DateTime? trailingDate; + final Widget? suffix; + final Widget? action; const CampusSection({ super.key, required this.title, required this.child, - this.trailingLabel, - this.onTapTrailing, - this.trailingDate, + this.suffix, + this.action, }); @override @@ -30,32 +26,10 @@ class CampusSection extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Flexible(child: _buildTitle()), - if (trailingLabel != null) - GestureDetector( - onTap: onTapTrailing, - behavior: HitTestBehavior.opaque, - child: Row( - children: [ - Text( - trailingLabel!, - style: TextStyles.smallTextRegular.copyWith( - color: ColorStyles.gray5, - ), - ), - const SizedBox(width: 2), - const Icon( - Icons.arrow_forward_ios, - size: 12, - color: ColorStyles.gray5, - ), - ], - ), - ), - ], + SectionHeader( + title: title, + suffix: suffix, + action: action, ), const SizedBox(height: 12), child, @@ -63,32 +37,4 @@ class CampusSection extends StatelessWidget { ), ); } - - Widget _buildTitle() { - final titleStyle = TextStyles.largeTextBold.copyWith( - color: ColorStyles.black, - ); - - if (trailingDate == null) { - return Text(title, style: titleStyle); - } - - return Text.rich( - TextSpan( - style: titleStyle, - children: [ - TextSpan(text: title), - TextSpan( - text: ' · ', - style: titleStyle.copyWith(color: ColorStyles.gray3), - ), - TextSpan( - text: DateFormat('M월 d일(E)', 'ko').format(trailingDate!), - style: titleStyle.copyWith(color: ColorStyles.gray6), - ), - ], - ), - overflow: TextOverflow.ellipsis, - ); - } } From 40386fb2ab3cebfa90ea86e7fc3af9c45d2bc0f0 Mon Sep 17 00:00:00 2001 From: rdyjun Date: Sat, 5 Sep 2026 18:21:27 +0900 Subject: [PATCH 20/40] refactor: sync latest campus base architecture --- lib/presentation/campus/campus_page_screen.dart | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/lib/presentation/campus/campus_page_screen.dart b/lib/presentation/campus/campus_page_screen.dart index 83ad02cb..599ef4d3 100644 --- a/lib/presentation/campus/campus_page_screen.dart +++ b/lib/presentation/campus/campus_page_screen.dart @@ -1,3 +1,4 @@ +import 'package:dongsoop/core/presentation/components/section_header.dart'; import 'package:dongsoop/core/routing/route_paths.dart'; import 'package:dongsoop/presentation/campus/widgets/campus_link_card.dart'; import 'package:dongsoop/presentation/campus/widgets/campus_meal_card.dart'; @@ -51,13 +52,20 @@ class CampusPageScreen extends StatelessWidget { const CampusSearchBar(), CampusSection( title: '오늘 뭐 먹지', - trailingLabel: '전체', - onTapTrailing: () => context.push(RoutePaths.restaurants), + action: SectionHeaderAction( + label: '전체', + onTap: () => context.push(RoutePaths.restaurants), + ), child: const CampusRestaurantList(), ), CampusSection( title: '학식', - trailingDate: today, + suffix: Text( + '· ${DateFormat('M월 d일(E)', 'ko').format(today)}', + style: TextStyles.largeTextBold.copyWith( + color: ColorStyles.gray6, + ), + ), child: const CampusMealCard(), ), CampusSection( From 0f8594d09254f40fa600bc07169501498e2ed568 Mon Sep 17 00:00:00 2001 From: rdyjun Date: Sat, 5 Sep 2026 18:21:38 +0900 Subject: [PATCH 21/40] refactor: reuse common section header for notices --- .../home/widgets/home_notice_list.dart | 37 ++++--------------- 1 file changed, 7 insertions(+), 30 deletions(-) diff --git a/lib/presentation/home/widgets/home_notice_list.dart b/lib/presentation/home/widgets/home_notice_list.dart index af5204af..01ea8c69 100644 --- a/lib/presentation/home/widgets/home_notice_list.dart +++ b/lib/presentation/home/widgets/home_notice_list.dart @@ -1,4 +1,5 @@ import 'package:dongsoop/core/presentation/components/notice_setting_link.dart'; +import 'package:dongsoop/core/presentation/components/section_header.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'; @@ -23,36 +24,12 @@ class HomeNoticeList extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - '새로운 공지', - style: TextStyles.largeTextBold.copyWith( - color: ColorStyles.black, - ), - ), - GestureDetector( - behavior: HitTestBehavior.opaque, - onTap: () => context.goNamed('noticeList'), - child: Row( - children: [ - Text( - '더보기', - style: TextStyles.smallTextRegular.copyWith( - color: ColorStyles.gray5, - ), - ), - const SizedBox(width: 2), - const Icon( - Icons.arrow_forward_ios, - size: 12, - color: ColorStyles.gray5, - ), - ], - ), - ), - ], + SectionHeader( + title: '새로운 공지', + action: SectionHeaderAction( + label: '더보기', + onTap: () => context.goNamed('noticeList'), + ), ), const SizedBox(height: 4), if (notices.isEmpty) From c5c576dcd2001572b974b14a4a551a736498154d Mon Sep 17 00:00:00 2001 From: rdyjun Date: Sat, 5 Sep 2026 18:21:41 +0900 Subject: [PATCH 22/40] refactor: reuse common section header for quick links --- lib/presentation/home/widgets/home_quick_links.dart | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/lib/presentation/home/widgets/home_quick_links.dart b/lib/presentation/home/widgets/home_quick_links.dart index 0d4dfc2c..675df3ab 100644 --- a/lib/presentation/home/widgets/home_quick_links.dart +++ b/lib/presentation/home/widgets/home_quick_links.dart @@ -1,3 +1,4 @@ +import 'package:dongsoop/core/presentation/components/section_header.dart'; import 'package:dongsoop/core/routing/route_paths.dart'; import 'package:dongsoop/ui/color_styles.dart'; import 'package:dongsoop/ui/text_styles.dart'; @@ -24,10 +25,7 @@ class HomeQuickLinks extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - '바로가기', - style: TextStyles.largeTextBold.copyWith(color: ColorStyles.black), - ), + const SectionHeader(title: '바로가기'), const SizedBox(height: 12), Row( children: [ From 9e7c046d737b1ab26e5110475978a82898d942ca Mon Sep 17 00:00:00 2001 From: rdyjun Date: Sat, 5 Sep 2026 18:21:51 +0900 Subject: [PATCH 23/40] refactor: remove obsolete cafeteria card after home redesign --- .../home/widgets/cafeteria_card.dart | 245 ------------------ 1 file changed, 245 deletions(-) delete mode 100644 lib/presentation/home/widgets/cafeteria_card.dart 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), - ), - ), - ), - ), - ); - } -} - From 04d741f6af0b0929ad0e87e3c9d2b1166e169402 Mon Sep 17 00:00:00 2001 From: rdyjun Date: Sat, 5 Sep 2026 18:21:58 +0900 Subject: [PATCH 24/40] refactor: reuse shared time string formatting --- lib/core/utils/time_formatter.dart | 46 ++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/lib/core/utils/time_formatter.dart b/lib/core/utils/time_formatter.dart index c3be0cab..e0e4661e 100644 --- a/lib/core/utils/time_formatter.dart +++ b/lib/core/utils/time_formatter.dart @@ -33,6 +33,52 @@ String formatDuration(int seconds) { } extension TimeStringExtensions on String { + /// 서버 시간 문자열(H:mm, HH:mm, HH:mm:ss)을 HH:mm으로 정규화한다. + /// + /// 파싱할 수 없는 값은 원문을 그대로 반환한다. + String toHourMinute() { + final match = RegExp(r'^\s*(\d{1,2}):(\d{2})(?::\d{2})?\s* + final parts = split(':'); + final hour = int.tryParse(parts[0]) ?? 0; + final minute = int.tryParse(parts[1]) ?? 0; + return (hour - 9) * 60 + minute; + } +} + +extension MinutesToTimeString on int { + String toTimeStringFrom9AM() { + final totalMinutes = this + 9 * 60; + final hour = totalMinutes ~/ 60; + final minute = totalMinutes % 60; + + final hh = hour.toString().padLeft(2, '0'); + final mm = minute.toString().padLeft(2, '0'); + + return '$hh:$mm'; + } +} + +String formatBlindDate(DateTime dt) { + String two(int n) => n.toString().padLeft(2, '0'); + return '${dt.year}. ${dt.month}. ${dt.day}. ${two(dt.hour)}:${two(dt.minute)}'; +} + +String formatYmd(DateTime d) { + final y = d.year.toString().padLeft(4, '0'); + final m = d.month.toString().padLeft(2, '0'); + final da = d.day.toString().padLeft(2, '0'); + return '$y-$m-$da'; +} + +String formatYmdDot(DateTime dt) => '${dt.year}. ${dt.month}. ${dt.day}.';) + .firstMatch(this); + if (match == null) return this; + + final hour = match.group(1)!.padLeft(2, '0'); + final minute = match.group(2)!; + return '$hour:$minute'; + } + int toMinutesFrom9AM() { final parts = split(':'); final hour = int.tryParse(parts[0]) ?? 0; From 00b784d468712724a860dba69e3f54f5f26b6e43 Mon Sep 17 00:00:00 2001 From: rdyjun Date: Sat, 5 Sep 2026 18:22:06 +0900 Subject: [PATCH 25/40] fix: reuse shared time formatting in home timetable --- lib/presentation/home/widgets/home_today_card.dart | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/presentation/home/widgets/home_today_card.dart b/lib/presentation/home/widgets/home_today_card.dart index 5769efd6..72573150 100644 --- a/lib/presentation/home/widgets/home_today_card.dart +++ b/lib/presentation/home/widgets/home_today_card.dart @@ -1,4 +1,5 @@ import 'package:dongsoop/core/routing/route_paths.dart'; +import 'package:dongsoop/core/utils/time_formatter.dart'; import 'package:dongsoop/domain/cafeteria/entities/cafeteria_entity.dart'; import 'package:dongsoop/domain/home/entity/home_entity.dart'; import 'package:dongsoop/presentation/home/view_models/cafeteria_view_model.dart'; @@ -106,7 +107,7 @@ class HomeTodayCard extends HookConsumerWidget { emoji: '📘', background: ColorStyles.primary5, title: slot.title, - description: '${slot.startAt} - ${slot.endAt}', + description: '${slot.startAt.toHourMinute()} - ${slot.endAt.toHourMinute()}', showChevron: true, ); } From 428f8314020d9710e306bae0ba96fdc90937a01f Mon Sep 17 00:00:00 2001 From: rdyjun Date: Sat, 5 Sep 2026 18:22:58 +0900 Subject: [PATCH 26/40] refactor: reuse restaurant webview route constant --- lib/presentation/campus/widgets/campus_restaurant_list.dart | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/presentation/campus/widgets/campus_restaurant_list.dart b/lib/presentation/campus/widgets/campus_restaurant_list.dart index 52ad26de..5693e93f 100644 --- a/lib/presentation/campus/widgets/campus_restaurant_list.dart +++ b/lib/presentation/campus/widgets/campus_restaurant_list.dart @@ -1,3 +1,4 @@ +import 'package:dongsoop/core/routing/route_paths.dart'; import 'package:dongsoop/domain/restaurants/model/restaurant.dart'; import 'package:dongsoop/presentation/campus/restaurant_category_style.dart'; import 'package:dongsoop/presentation/campus/providers/top_restaurants_provider.dart'; @@ -75,7 +76,7 @@ class _RestaurantCard extends StatelessWidget { final url = restaurant.placeUrl; if (url == null || url.isEmpty) return; final location = Uri( - path: '/restaurantWebView', + path: RoutePaths.restaurantWebView, queryParameters: {'url': url}, ).toString(); context.push(location); From c0745fb10704d6951b011e5fe5692a853dc7badd Mon Sep 17 00:00:00 2001 From: rdyjun Date: Sat, 5 Sep 2026 18:23:54 +0900 Subject: [PATCH 27/40] refactor: sync shared authenticated action --- .../components/authenticated_action.dart | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 lib/core/presentation/components/authenticated_action.dart diff --git a/lib/core/presentation/components/authenticated_action.dart b/lib/core/presentation/components/authenticated_action.dart new file mode 100644 index 00000000..4780854e --- /dev/null +++ b/lib/core/presentation/components/authenticated_action.dart @@ -0,0 +1,21 @@ +import 'dart:async'; + +import 'package:dongsoop/core/presentation/components/login_required_dialog.dart'; +import 'package:flutter/material.dart'; + +/// 인증이 필요한 UI 액션의 공통 가드. +/// +/// 호출부는 인증 여부와 실제 동작만 전달한다. 인증 정책이 바뀌어도 +/// 각 화면의 onTap 구현을 반복 수정하지 않도록 한 곳에서 처리한다. +Future runAuthenticatedAction( + BuildContext context, { + required bool isAuthenticated, + required FutureOr Function() action, +}) async { + if (!isAuthenticated) { + await LoginRequiredDialog(context); + return; + } + + await action(); +} From dce83f1083cf06de9bfa82436a7ef7c733603b1d Mon Sep 17 00:00:00 2001 From: rdyjun Date: Sat, 5 Sep 2026 18:23:57 +0900 Subject: [PATCH 28/40] refactor: sync reviewed campus base --- lib/presentation/campus/campus_page_screen.dart | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/lib/presentation/campus/campus_page_screen.dart b/lib/presentation/campus/campus_page_screen.dart index 599ef4d3..07c153e8 100644 --- a/lib/presentation/campus/campus_page_screen.dart +++ b/lib/presentation/campus/campus_page_screen.dart @@ -1,3 +1,4 @@ +import 'package:dongsoop/core/presentation/components/authenticated_action.dart'; import 'package:dongsoop/core/presentation/components/section_header.dart'; import 'package:dongsoop/core/routing/route_paths.dart'; import 'package:dongsoop/presentation/campus/widgets/campus_link_card.dart'; @@ -5,22 +6,25 @@ import 'package:dongsoop/presentation/campus/widgets/campus_meal_card.dart'; import 'package:dongsoop/presentation/campus/widgets/campus_restaurant_list.dart'; import 'package:dongsoop/presentation/campus/widgets/campus_search_bar.dart'; import 'package:dongsoop/presentation/campus/widgets/campus_section.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'; import 'package:intl/intl.dart'; /// 학교 다니면서 쓰는 것들을 모아둔 탭. /// /// 게시판이 빠진 자리다. 맛집·학식·도서관·챗봇은 "모임" 이 아니라 "탐색" 에 /// 가까워서 탭 이름도 모여봐요에서 캠퍼스로 바꿨다. -class CampusPageScreen extends StatelessWidget { +class CampusPageScreen extends ConsumerWidget { const CampusPageScreen({super.key}); @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { final today = DateTime.now(); + final isAuthenticated = ref.watch(userSessionProvider) != null; return Scaffold( backgroundColor: ColorStyles.white, @@ -85,7 +89,11 @@ class CampusPageScreen extends StatelessWidget { title: '학사 챗봇', description: '학사일정·수강신청 물어보기', background: ColorStyles.primary5, - onTap: () => context.push(RoutePaths.chatbot), + onTap: () => runAuthenticatedAction( + context, + isAuthenticated: isAuthenticated, + action: () => context.push(RoutePaths.chatbot), + ), ), const SizedBox(height: 10), CampusLinkCard( From b17afb0a245e2776e1bdbaa3f1b6c72eddcee803 Mon Sep 17 00:00:00 2001 From: rdyjun Date: Sat, 5 Sep 2026 18:24:00 +0900 Subject: [PATCH 29/40] refactor: sync reviewed campus base From a71bceed307f2c69d4e0f7fc6ae7c4fa3404f941 Mon Sep 17 00:00:00 2001 From: rdyjun Date: Sat, 5 Sep 2026 18:24:15 +0900 Subject: [PATCH 30/40] fix: make quick-link auth requirements data-driven --- .../home/widgets/home_quick_links.dart | 77 ++++++++++++++++--- 1 file changed, 65 insertions(+), 12 deletions(-) diff --git a/lib/presentation/home/widgets/home_quick_links.dart b/lib/presentation/home/widgets/home_quick_links.dart index 675df3ab..e9b6520a 100644 --- a/lib/presentation/home/widgets/home_quick_links.dart +++ b/lib/presentation/home/widgets/home_quick_links.dart @@ -1,23 +1,49 @@ +import 'package:dongsoop/core/presentation/components/authenticated_action.dart'; import 'package:dongsoop/core/presentation/components/section_header.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 StatelessWidget { +class HomeQuickLinks extends ConsumerWidget { const HomeQuickLinks({super.key}); @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { + final isAuthenticated = ref.watch( + userSessionProvider.select((user) => user != null), + ); + final items = <_QuickItem>[ - _QuickItem('🍽️', '맛집', ColorStyles.primary5, RoutePaths.restaurants), - _QuickItem('📚', '도서관', ColorStyles.mintBg, RoutePaths.libraryWebView), - _QuickItem('💬', '챗봇', ColorStyles.amberBg, RoutePaths.chatbot), - _QuickItem('🗓️', '학사일정', ColorStyles.gray1, RoutePaths.schedule), + const _QuickItem( + emoji: '🍽️', + label: '맛집', + background: ColorStyles.primary5, + path: RoutePaths.restaurants, + ), + const _QuickItem( + emoji: '📚', + label: '도서관', + background: ColorStyles.mintBg, + path: RoutePaths.libraryWebView, + ), + const _QuickItem( + emoji: '💬', + label: '챗봇', + background: ColorStyles.amberBg, + path: RoutePaths.chatbot, + requiresAuth: true, + ), + const _QuickItem( + emoji: '🗓️', + label: '학사일정', + background: ColorStyles.gray1, + path: RoutePaths.schedule, + ), ]; return Padding( @@ -31,7 +57,12 @@ class HomeQuickLinks extends StatelessWidget { children: [ for (var i = 0; i < items.length; i++) ...[ if (i > 0) const SizedBox(width: 8), - Expanded(child: _QuickTile(item: items[i])), + Expanded( + child: _QuickTile( + item: items[i], + isAuthenticated: isAuthenticated, + ), + ), ], ], ), @@ -46,14 +77,25 @@ class _QuickItem { final String label; final Color background; final String path; + final bool requiresAuth; - const _QuickItem(this.emoji, this.label, this.background, this.path); + const _QuickItem({ + required this.emoji, + required this.label, + required this.background, + required this.path, + this.requiresAuth = false, + }); } class _QuickTile extends StatelessWidget { final _QuickItem item; + final bool isAuthenticated; - const _QuickTile({required this.item}); + const _QuickTile({ + required this.item, + required this.isAuthenticated, + }); @override Widget build(BuildContext context) { @@ -61,7 +103,18 @@ class _QuickTile extends StatelessWidget { color: ColorStyles.gray7, borderRadius: BorderRadius.circular(16), child: InkWell( - onTap: () => context.push(item.path), + onTap: () { + if (!item.requiresAuth) { + context.push(item.path); + return; + } + + runAuthenticatedAction( + context, + isAuthenticated: isAuthenticated, + action: () => context.push(item.path), + ); + }, borderRadius: BorderRadius.circular(16), child: Padding( padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 4), From 6f9375072ee6b1120ae1e9700cb8f71e5b3e4e41 Mon Sep 17 00:00:00 2001 From: rdyjun Date: Sat, 5 Sep 2026 18:24:47 +0900 Subject: [PATCH 31/40] refactor: refresh all visible home data and reuse auth guard --- lib/presentation/home/home_page_screen.dart | 23 +++++++++++++-------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/lib/presentation/home/home_page_screen.dart b/lib/presentation/home/home_page_screen.dart index 3f2afba7..1b8a6a96 100644 --- a/lib/presentation/home/home_page_screen.dart +++ b/lib/presentation/home/home_page_screen.dart @@ -1,5 +1,5 @@ import 'package:dongsoop/core/presentation/components/admob_banner_ad.dart'; -import 'package:dongsoop/core/presentation/components/login_required_dialog.dart'; +import 'package:dongsoop/core/presentation/components/authenticated_action.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_greeting.dart'; @@ -11,6 +11,7 @@ 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:dongsoop/presentation/home/view_models/cafeteria_view_model.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'; @@ -57,13 +58,11 @@ class HomePageScreen extends HookConsumerWidget { floatingActionButton: Padding( padding: EdgeInsets.only(bottom: 24,), child: ChatbotButton( - onTap: () async { - if (user == null) { - await LoginRequiredDialog(context); - } else { - onTapChatbot(); // 기존 이동/동작 - } - }, + onTap: () => runAuthenticatedAction( + context, + isAuthenticated: user != null, + action: onTapChatbot, + ), ), ), appBar: MainHeader( @@ -81,7 +80,13 @@ class HomePageScreen extends HookConsumerWidget { top: false, bottom: true, child: RefreshIndicator( - onRefresh: homeViewModel.refresh, + onRefresh: () async { + await Future.wait([ + homeViewModel.refresh(), + ref.refresh(cafeteriaViewModelProvider.future), + if (user != null) badge.refreshBadge(force: true), + ]); + }, color: ColorStyles.primary100, child: ListView( padding: EdgeInsets.zero, From e313e7d8b2a4887e1c7eb03fe641658eb4af6e81 Mon Sep 17 00:00:00 2001 From: rdyjun Date: Sat, 5 Sep 2026 18:25:36 +0900 Subject: [PATCH 32/40] fix: restore shared time formatter and add HH:mm helper safely --- lib/core/utils/time_formatter.dart | 46 ++++++------------------------ 1 file changed, 8 insertions(+), 38 deletions(-) diff --git a/lib/core/utils/time_formatter.dart b/lib/core/utils/time_formatter.dart index e0e4661e..1a7f46b7 100644 --- a/lib/core/utils/time_formatter.dart +++ b/lib/core/utils/time_formatter.dart @@ -37,46 +37,16 @@ extension TimeStringExtensions on String { /// /// 파싱할 수 없는 값은 원문을 그대로 반환한다. String toHourMinute() { - final match = RegExp(r'^\s*(\d{1,2}):(\d{2})(?::\d{2})?\s* - final parts = split(':'); - final hour = int.tryParse(parts[0]) ?? 0; - final minute = int.tryParse(parts[1]) ?? 0; - return (hour - 9) * 60 + minute; - } -} - -extension MinutesToTimeString on int { - String toTimeStringFrom9AM() { - final totalMinutes = this + 9 * 60; - final hour = totalMinutes ~/ 60; - final minute = totalMinutes % 60; - - final hh = hour.toString().padLeft(2, '0'); - final mm = minute.toString().padLeft(2, '0'); - - return '$hh:$mm'; - } -} - -String formatBlindDate(DateTime dt) { - String two(int n) => n.toString().padLeft(2, '0'); - return '${dt.year}. ${dt.month}. ${dt.day}. ${two(dt.hour)}:${two(dt.minute)}'; -} - -String formatYmd(DateTime d) { - final y = d.year.toString().padLeft(4, '0'); - final m = d.month.toString().padLeft(2, '0'); - final da = d.day.toString().padLeft(2, '0'); - return '$y-$m-$da'; -} + final parts = trim().split(':'); + if (parts.length < 2) return this; -String formatYmdDot(DateTime dt) => '${dt.year}. ${dt.month}. ${dt.day}.';) - .firstMatch(this); - if (match == null) return this; + final hour = int.tryParse(parts[0]); + final minute = int.tryParse(parts[1]); + if (hour == null || minute == null || hour < 0 || hour > 23 || minute < 0 || minute > 59) { + return this; + } - final hour = match.group(1)!.padLeft(2, '0'); - final minute = match.group(2)!; - return '$hour:$minute'; + return '${hour.toString().padLeft(2, '0')}:${minute.toString().padLeft(2, '0')}'; } int toMinutesFrom9AM() { From 41cd55447a9589319397bf781660f471d797fd3d Mon Sep 17 00:00:00 2001 From: rdyjun Date: Sat, 5 Sep 2026 18:26:07 +0900 Subject: [PATCH 33/40] refactor: keep authenticated UI action guard minimal --- lib/core/presentation/components/authenticated_action.dart | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/lib/core/presentation/components/authenticated_action.dart b/lib/core/presentation/components/authenticated_action.dart index 4780854e..bc8a6195 100644 --- a/lib/core/presentation/components/authenticated_action.dart +++ b/lib/core/presentation/components/authenticated_action.dart @@ -1,5 +1,3 @@ -import 'dart:async'; - import 'package:dongsoop/core/presentation/components/login_required_dialog.dart'; import 'package:flutter/material.dart'; @@ -10,12 +8,12 @@ import 'package:flutter/material.dart'; Future runAuthenticatedAction( BuildContext context, { required bool isAuthenticated, - required FutureOr Function() action, + required VoidCallback action, }) async { if (!isAuthenticated) { await LoginRequiredDialog(context); return; } - await action(); + action(); } From c418e268e2ceb9f5e0e876ce3d9932b57a51ffa5 Mon Sep 17 00:00:00 2001 From: rdyjun Date: Sat, 5 Sep 2026 18:33:25 +0900 Subject: [PATCH 34/40] ci: cancel stale iOS runs for newer PR commits --- .github/workflows/flutter-ios.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/flutter-ios.yml b/.github/workflows/flutter-ios.yml index 77c89a3f..6a6b2c09 100644 --- a/.github/workflows/flutter-ios.yml +++ b/.github/workflows/flutter-ios.yml @@ -6,6 +6,10 @@ on: pull_request: branches: [ develop ] +concurrency: + group: flutter-ios-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + jobs: build-ios: timeout-minutes: 15 From c5959823a5167fefab783041a073df07ac39a7a3 Mon Sep 17 00:00:00 2001 From: rdyjun Date: Sat, 5 Sep 2026 18:33:29 +0900 Subject: [PATCH 35/40] chore: remove unused restaurants provider import --- lib/providers/restaurants_providers.dart | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/providers/restaurants_providers.dart b/lib/providers/restaurants_providers.dart index c145a7d1..beebc0a2 100644 --- a/lib/providers/restaurants_providers.dart +++ b/lib/providers/restaurants_providers.dart @@ -17,7 +17,6 @@ import 'package:dongsoop/presentation/restaurants/write/restaurants_write_view_m import 'package:dongsoop/presentation/restaurants/write/search_kakao_state.dart'; import 'package:dongsoop/presentation/restaurants/write/search_kakao_view_model.dart'; import 'package:dongsoop/providers/plain_dio.dart'; -import 'package:dongsoop/providers/auth_providers.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'auth_dio.dart'; From e8a461e9631f746bddb75281c3002e63bb90477c Mon Sep 17 00:00:00 2001 From: rdyjun Date: Sat, 5 Sep 2026 21:53:32 +0900 Subject: [PATCH 36/40] fix: keep home redesign on existing UI and API patterns --- .../components/admob_banner_ad.dart | 157 ----------------- lib/core/utils/time_formatter.dart | 16 -- lib/presentation/home/home_page_screen.dart | 46 ++--- .../home/widgets/home_greeting.dart | 35 ++-- .../home/widgets/home_notice_list.dart | 46 +++-- .../home/widgets/home_quick_links.dart | 127 ++++++-------- .../home/widgets/home_today_card.dart | 161 +++++++++++------- 7 files changed, 219 insertions(+), 369 deletions(-) delete mode 100644 lib/core/presentation/components/admob_banner_ad.dart diff --git a/lib/core/presentation/components/admob_banner_ad.dart b/lib/core/presentation/components/admob_banner_ad.dart deleted file mode 100644 index 9ad30def..00000000 --- a/lib/core/presentation/components/admob_banner_ad.dart +++ /dev/null @@ -1,157 +0,0 @@ -import 'dart:io'; - -import 'package:dongsoop/core/environment/app_distribution.dart'; -import 'package:dongsoop/ui/color_styles.dart'; -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_dotenv/flutter_dotenv.dart'; -import 'package:google_mobile_ads/google_mobile_ads.dart'; -import 'package:logger/logger.dart'; - -/// 홈의 스크롤 콘텐츠 안에 배치되는 인라인 적응형 배너 광고. -/// -/// 광고가 아직 로드되지 않았거나 실패하면 빈 공간을 남기지 않는다. -/// 화면 폭/방향이 바뀌면 해당 크기에 맞춰 광고를 다시 요청한다. -class AdmobBannerAd extends StatefulWidget { - const AdmobBannerAd({super.key}); - - @override - State createState() => _AdmobBannerAdState(); -} - -class _AdmobBannerAdState extends State { - BannerAd? _bannerAd; - AdSize? _loadedSize; - bool _isLoaded = false; - bool _useTestAds = kDebugMode; - int? _requestedWidth; - Orientation? _requestedOrientation; - int _requestGeneration = 0; - - final Logger _logger = Logger(); - - static const String _androidTestAdUnitId = - 'ca-app-pub-3940256099942544/9214589741'; - static const String _iosTestAdUnitId = - 'ca-app-pub-3940256099942544/2435281174'; - - String get _adUnitId { - if (Platform.isAndroid) { - if (_useTestAds) return _androidTestAdUnitId; - return dotenv.maybeGet('ADMOB_ANDROID_BANNER_ID') ?? _androidTestAdUnitId; - } - - if (Platform.isIOS) { - if (_useTestAds) return _iosTestAdUnitId; - return dotenv.maybeGet('ADMOB_IOS_BANNER_ID') ?? _iosTestAdUnitId; - } - - return ''; - } - - @override - void didChangeDependencies() { - super.didChangeDependencies(); - - final mediaQuery = MediaQuery.of(context); - final width = mediaQuery.size.width.truncate(); - final orientation = mediaQuery.orientation; - - if (_requestedWidth == width && _requestedOrientation == orientation) { - return; - } - - _requestedWidth = width; - _requestedOrientation = orientation; - _loadInlineAdaptiveAd(width); - } - - Future _loadInlineAdaptiveAd(int width) async { - final generation = ++_requestGeneration; - final isTestFlight = await AppDistribution.isTestFlight(); - - if (!mounted || generation != _requestGeneration) return; - - _useTestAds = kDebugMode || isTestFlight; - - final previousAd = _bannerAd; - _bannerAd = null; - _loadedSize = null; - _isLoaded = false; - await previousAd?.dispose(); - - if (!mounted || generation != _requestGeneration) return; - - final adUnitId = _adUnitId; - if (adUnitId.isEmpty) return; - - // 홈은 ListView 안에 있으므로 anchored가 아니라 inline adaptive를 사용한다. - final requestSize = AdSize.getInlineAdaptiveBannerAdSize(width, 100); - - late final BannerAd ad; - ad = BannerAd( - adUnitId: adUnitId, - size: requestSize, - request: const AdRequest(), - listener: BannerAdListener( - onAdLoaded: (_) async { - if (!mounted || generation != _requestGeneration) { - await ad.dispose(); - return; - } - - final platformSize = await ad.getPlatformAdSize(); - if (!mounted || generation != _requestGeneration) { - await ad.dispose(); - return; - } - - setState(() { - _loadedSize = platformSize ?? ad.size; - _isLoaded = true; - }); - }, - onAdFailedToLoad: (failedAd, error) { - _logger.d('AdMob banner failed: $error'); - failedAd.dispose(); - - if (!mounted || generation != _requestGeneration) return; - - setState(() { - _bannerAd = null; - _loadedSize = null; - _isLoaded = false; - }); - }, - ), - ); - - _bannerAd = ad; - ad.load(); - } - - @override - void dispose() { - _requestGeneration++; - _bannerAd?.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - final ad = _bannerAd; - final size = _loadedSize; - - if (!_isLoaded || ad == null || size == null) { - return const SizedBox.shrink(); - } - - return Container( - alignment: Alignment.center, - color: ColorStyles.white, - width: size.width.toDouble(), - height: size.height.toDouble(), - child: AdWidget(ad: ad), - ); - } -} diff --git a/lib/core/utils/time_formatter.dart b/lib/core/utils/time_formatter.dart index 1a7f46b7..c3be0cab 100644 --- a/lib/core/utils/time_formatter.dart +++ b/lib/core/utils/time_formatter.dart @@ -33,22 +33,6 @@ String formatDuration(int seconds) { } extension TimeStringExtensions on String { - /// 서버 시간 문자열(H:mm, HH:mm, HH:mm:ss)을 HH:mm으로 정규화한다. - /// - /// 파싱할 수 없는 값은 원문을 그대로 반환한다. - String toHourMinute() { - final parts = trim().split(':'); - if (parts.length < 2) return this; - - final hour = int.tryParse(parts[0]); - final minute = int.tryParse(parts[1]); - if (hour == null || minute == null || hour < 0 || hour > 23 || minute < 0 || minute > 59) { - return this; - } - - return '${hour.toString().padLeft(2, '0')}:${minute.toString().padLeft(2, '0')}'; - } - int toMinutesFrom9AM() { final parts = split(':'); final hour = int.tryParse(parts[0]) ?? 0; diff --git a/lib/presentation/home/home_page_screen.dart b/lib/presentation/home/home_page_screen.dart index 1b8a6a96..97cf5d9d 100644 --- a/lib/presentation/home/home_page_screen.dart +++ b/lib/presentation/home/home_page_screen.dart @@ -1,5 +1,5 @@ -import 'package:dongsoop/core/presentation/components/admob_banner_ad.dart'; -import 'package:dongsoop/core/presentation/components/authenticated_action.dart'; +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_greeting.dart'; @@ -11,7 +11,7 @@ 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:dongsoop/presentation/home/view_models/cafeteria_view_model.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'; @@ -58,11 +58,13 @@ class HomePageScreen extends HookConsumerWidget { floatingActionButton: Padding( padding: EdgeInsets.only(bottom: 24,), child: ChatbotButton( - onTap: () => runAuthenticatedAction( - context, - isAuthenticated: user != null, - action: onTapChatbot, - ), + onTap: () async { + if (user == null) { + await LoginRequiredDialog(context); + return; + } + onTapChatbot(); + }, ), ), appBar: MainHeader( @@ -80,24 +82,30 @@ class HomePageScreen extends HookConsumerWidget { top: false, bottom: true, child: RefreshIndicator( - onRefresh: () async { - await Future.wait([ - homeViewModel.refresh(), - ref.refresh(cafeteriaViewModelProvider.future), - if (user != null) badge.refreshBadge(force: true), - ]); - }, + onRefresh: homeViewModel.refresh, color: ColorStyles.primary100, child: ListView( padding: EdgeInsets.zero, children: [ // 공지는 세 건으로 잘려 오고 읽음 여부도 몰라 아직 세지 않는다 - HomeGreeting(classCount: homeEntity.timeTable.length), - HomeTodayCard(timeTable: homeEntity.timeTable), + HomeGreeting( + classCount: homeEntity.timeTable.length, + isLoggedOut: user == null, + ), + HomeTodayCard( + timeTable: homeEntity.timeTable, + isLoggedOut: user == null, + ), HomeNoticeList(notices: homeEntity.notices), const Padding( - padding: EdgeInsets.symmetric(vertical: 22), - child: AdmobBannerAd(), + padding: EdgeInsets.symmetric(vertical: 22, horizontal: 16), + child: SizedBox( + height: 100, + child: AdmobNativeAd( + templateType: TemplateType.small, + height: 100, + ), + ), ), const HomeQuickLinks(), const SizedBox(height: 24), diff --git a/lib/presentation/home/widgets/home_greeting.dart b/lib/presentation/home/widgets/home_greeting.dart index 3bed32fb..a8b617bb 100644 --- a/lib/presentation/home/widgets/home_greeting.dart +++ b/lib/presentation/home/widgets/home_greeting.dart @@ -3,16 +3,15 @@ import 'package:dongsoop/ui/text_styles.dart'; import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; -/// 목록을 읽기 전에 오늘 상태부터 알리는 첫 문장. -/// -/// 0 인 항목은 문장에서 뺀다. 둘 다 없으면 다른 문장으로 바꾼다 — -/// "수업 0개, 공지 0개" 는 읽는 사람에게 아무것도 알려주지 않는다. +/// 기존 홈 응답의 수업 정보로 보여주는 인사말. class HomeGreeting extends StatelessWidget { final int classCount; + final bool isLoggedOut; const HomeGreeting({ super.key, required this.classCount, + required this.isLoggedOut, }); @override @@ -40,34 +39,20 @@ class HomeGreeting extends StatelessWidget { } TextSpan _buildMessage() { - final parts = []; - - if (classCount > 0) { - parts.add(_countPhrase('오늘 수업 ', classCount)); - } - if (parts.isEmpty) { - return const TextSpan(text: '오늘은 일정이 없어요'); + if (isLoggedOut) { + 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]); + if (classCount == 0) { + return const TextSpan(text: '오늘은 수업이 없어요'); } - children.add(const TextSpan(text: ' 있어요')); - - return TextSpan(children: children); - } - - /// 숫자만 강조색으로 띄워 눈이 먼저 가게 한다. - TextSpan _countPhrase(String label, int count) { return TextSpan( children: [ - TextSpan(text: label), + const TextSpan(text: '오늘 수업 '), TextSpan( - text: '$count개', + text: '$classCount개', style: const TextStyle(color: ColorStyles.primary100), ), + const TextSpan(text: ' 있어요'), ], ); } diff --git a/lib/presentation/home/widgets/home_notice_list.dart b/lib/presentation/home/widgets/home_notice_list.dart index 01ea8c69..a9d99230 100644 --- a/lib/presentation/home/widgets/home_notice_list.dart +++ b/lib/presentation/home/widgets/home_notice_list.dart @@ -1,5 +1,4 @@ import 'package:dongsoop/core/presentation/components/notice_setting_link.dart'; -import 'package:dongsoop/core/presentation/components/section_header.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'; @@ -8,10 +7,6 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; /// 홈의 새로운 공지 세 건. -/// -/// 앞의 점은 읽음 여부 자리다. 지금은 서버 응답(`HomeNotice`)에 공지 id 가 없어 -/// 로컬 읽음 기록과 대조할 수 없으므로 전부 안 읽음으로 둔다. -/// 백엔드에서 id 가 내려오면 [isUnread] 만 채우면 된다. class HomeNoticeList extends StatelessWidget { final List notices; @@ -24,12 +19,33 @@ class HomeNoticeList extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - SectionHeader( - title: '새로운 공지', - action: SectionHeaderAction( - label: '더보기', - onTap: () => context.goNamed('noticeList'), - ), + 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) @@ -66,13 +82,9 @@ class _NoticeRow extends StatelessWidget { final Notice notice; final bool isLast; - /// 서버가 공지 id 를 내려주기 전까지는 모두 안 읽음으로 본다. - final bool isUnread; - const _NoticeRow({ required this.notice, required this.isLast, - this.isUnread = true, }); @override @@ -100,7 +112,7 @@ class _NoticeRow extends StatelessWidget { height: 6, margin: const EdgeInsets.only(top: 8, right: 10), decoration: BoxDecoration( - color: isUnread ? ColorStyles.primary100 : ColorStyles.gray2, + color: ColorStyles.primary100, borderRadius: BorderRadius.circular(3), ), ), @@ -113,7 +125,7 @@ class _NoticeRow extends StatelessWidget { maxLines: 2, overflow: TextOverflow.ellipsis, style: TextStyles.normalTextBold.copyWith( - color: isUnread ? ColorStyles.black : ColorStyles.gray6, + color: ColorStyles.black, height: 1.45, ), ), diff --git a/lib/presentation/home/widgets/home_quick_links.dart b/lib/presentation/home/widgets/home_quick_links.dart index e9b6520a..92743e50 100644 --- a/lib/presentation/home/widgets/home_quick_links.dart +++ b/lib/presentation/home/widgets/home_quick_links.dart @@ -1,5 +1,4 @@ -import 'package:dongsoop/core/presentation/components/authenticated_action.dart'; -import 'package:dongsoop/core/presentation/components/section_header.dart'; +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'; @@ -14,56 +13,61 @@ class HomeQuickLinks extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final isAuthenticated = ref.watch( - userSessionProvider.select((user) => user != null), - ); - - final items = <_QuickItem>[ - const _QuickItem( - emoji: '🍽️', - label: '맛집', - background: ColorStyles.primary5, - path: RoutePaths.restaurants, - ), - const _QuickItem( - emoji: '📚', - label: '도서관', - background: ColorStyles.mintBg, - path: RoutePaths.libraryWebView, - ), - const _QuickItem( - emoji: '💬', - label: '챗봇', - background: ColorStyles.amberBg, - path: RoutePaths.chatbot, - requiresAuth: true, - ), - const _QuickItem( - emoji: '🗓️', - label: '학사일정', - background: ColorStyles.gray1, - path: RoutePaths.schedule, - ), - ]; + final user = ref.watch(userSessionProvider); return Padding( padding: const EdgeInsets.fromLTRB(20, 22, 20, 0), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const SectionHeader(title: '바로가기'), + Text( + '바로가기', + style: TextStyles.largeTextBold.copyWith(color: ColorStyles.black), + ), const SizedBox(height: 12), Row( children: [ - for (var i = 0; i < items.length; i++) ...[ - if (i > 0) const SizedBox(width: 8), - Expanded( - child: _QuickTile( - item: items[i], - isAuthenticated: isAuthenticated, - ), + 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), + ), + ), ], ), ], @@ -72,29 +76,17 @@ class HomeQuickLinks extends ConsumerWidget { } } -class _QuickItem { +class _QuickTile extends StatelessWidget { final String emoji; final String label; final Color background; - final String path; - final bool requiresAuth; + final VoidCallback onTap; - const _QuickItem({ + const _QuickTile({ required this.emoji, required this.label, required this.background, - required this.path, - this.requiresAuth = false, - }); -} - -class _QuickTile extends StatelessWidget { - final _QuickItem item; - final bool isAuthenticated; - - const _QuickTile({ - required this.item, - required this.isAuthenticated, + required this.onTap, }); @override @@ -103,18 +95,7 @@ class _QuickTile extends StatelessWidget { color: ColorStyles.gray7, borderRadius: BorderRadius.circular(16), child: InkWell( - onTap: () { - if (!item.requiresAuth) { - context.push(item.path); - return; - } - - runAuthenticatedAction( - context, - isAuthenticated: isAuthenticated, - action: () => context.push(item.path), - ); - }, + onTap: onTap, borderRadius: BorderRadius.circular(16), child: Padding( padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 4), @@ -124,15 +105,15 @@ class _QuickTile extends StatelessWidget { width: 34, height: 34, decoration: BoxDecoration( - color: item.background, + color: background, borderRadius: BorderRadius.circular(12), ), alignment: Alignment.center, - child: Text(item.emoji, style: const TextStyle(fontSize: 17)), + child: Text(emoji, style: const TextStyle(fontSize: 17)), ), const SizedBox(height: 7), Text( - item.label, + label, style: TextStyles.smallTextBold.copyWith( color: ColorStyles.gray6, ), diff --git a/lib/presentation/home/widgets/home_today_card.dart b/lib/presentation/home/widgets/home_today_card.dart index 72573150..651fd7da 100644 --- a/lib/presentation/home/widgets/home_today_card.dart +++ b/lib/presentation/home/widgets/home_today_card.dart @@ -1,5 +1,4 @@ import 'package:dongsoop/core/routing/route_paths.dart'; -import 'package:dongsoop/core/utils/time_formatter.dart'; import 'package:dongsoop/domain/cafeteria/entities/cafeteria_entity.dart'; import 'package:dongsoop/domain/home/entity/home_entity.dart'; import 'package:dongsoop/presentation/home/view_models/cafeteria_view_model.dart'; @@ -18,15 +17,21 @@ import 'package:intl/intl.dart'; /// 우측 셰브런만 그 사실을 알린다. 제목 줄을 없애면 카드가 한 뼘 짧아진다. class HomeTodayCard extends HookConsumerWidget { final List timeTable; + final bool isLoggedOut; - const HomeTodayCard({super.key, required this.timeTable}); + const HomeTodayCard({ + super.key, + required this.timeTable, + required this.isLoggedOut, + }); @override Widget build(BuildContext context, WidgetRef ref) { - final meals = ref.watch(cafeteriaViewModelProvider).maybeWhen( - data: (data) => data.weekMeals, - orElse: () => const [], - ); + final cafeteriaState = ref.watch(cafeteriaViewModelProvider); + final meals = cafeteriaState.maybeWhen( + data: (data) => data.weekMeals, + orElse: () => const [], + ); final todayMealIndex = _findTodayMealIndex(meals); final mealIndex = useState(todayMealIndex); @@ -46,39 +51,60 @@ class HomeTodayCard extends HookConsumerWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - SwipeDeck( - itemCount: timeTable.isEmpty ? 1 : timeTable.length, - itemBuilder: (context, index) => timeTable.isEmpty - ? const _TodayRow( - 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), - ), - _MealHeader(meals: meals, index: mealIndex.value), - SwipeDeck( - itemCount: meals.isEmpty ? 1 : meals.length, - initialPage: mealIndex.value, - onPageChanged: (index) => mealIndex.value = index, - itemBuilder: (context, index) => meals.isEmpty - ? const _TodayRow( - emoji: '🍚', - background: ColorStyles.gray2, - title: '이번 주 학식 정보가 없어요', - isMuted: true, - ) - : _TodayRow( - emoji: '🍚', - background: ColorStyles.amberBg, - title: meals[index].koreanMenu, - ), + if (!isLoggedOut) ...[ + SwipeDeck( + itemCount: timeTable.isEmpty ? 1 : timeTable.length, + itemBuilder: (context, index) => timeTable.isEmpty + ? const _TodayRow( + 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), + ), + ], + cafeteriaState.when( + data: (_) => Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _MealHeader(meals: meals, index: mealIndex.value), + SwipeDeck( + itemCount: meals.isEmpty ? 1 : meals.length, + initialPage: mealIndex.value, + onPageChanged: (index) => mealIndex.value = index, + itemBuilder: (context, index) => meals.isEmpty + ? const _TodayRow( + emoji: '🍚', + background: ColorStyles.gray2, + title: '이번 주 학식 정보가 없어요', + isMuted: true, + ) + : _TodayRow( + emoji: '🍚', + background: ColorStyles.amberBg, + title: meals[index].koreanMenu, + ), + ), + ], + ), + loading: () => const _TodayRow( + emoji: '🍚', + background: ColorStyles.gray2, + title: '학식을 불러오는 중...', + isMuted: true, + ), + error: (_, __) => const _TodayRow( + emoji: '🍚', + background: ColorStyles.gray2, + title: '학식을 불러오지 못했어요', + isMuted: true, + ), ), ], ), @@ -107,10 +133,17 @@ class HomeTodayCard extends HookConsumerWidget { emoji: '📘', background: ColorStyles.primary5, title: slot.title, - description: '${slot.startAt.toHourMinute()} - ${slot.endAt.toHourMinute()}', + 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)!}'; + } } /// 학식 제목. 넘긴 날짜를 따라 `학식 · 9월 5일(금)` 로 바뀐다. @@ -124,31 +157,31 @@ class _MealHeader extends StatelessWidget { Widget build(BuildContext context) { final label = _dateLabel(index); return Padding( - padding: const EdgeInsets.fromLTRB(16, 0, 16, 10), - child: Text.rich( - TextSpan( - style: TextStyles.normalTextBold.copyWith( - color: ColorStyles.black, - ), - children: [ - const TextSpan(text: '학식'), - if (label != null) ...[ - TextSpan( - text: ' · ', - style: const TextStyle(color: ColorStyles.gray4), - ), - TextSpan( - text: label, - style: const TextStyle(color: ColorStyles.gray6), - ), - ], - ], - ), + padding: const EdgeInsets.fromLTRB(16, 0, 16, 10), + child: Text.rich( + TextSpan( + style: TextStyles.normalTextBold.copyWith( + color: ColorStyles.black, ), + children: [ + const TextSpan(text: '학식'), + if (label != null) ...[ + const TextSpan( + text: ' · ', + style: TextStyle(color: ColorStyles.gray4), + ), + TextSpan( + text: label, + style: const TextStyle(color: ColorStyles.gray6), + ), + ], + ], + ), + ), ); } - /// 서버가 `dayOfWeek` 를 함께 내려주므로 요일을 따로 계산하지 않는다. + /// 메뉴가 없는 날은 서버 요일이 비어 있으므로 기존 요일 표기를 사용한다. String? _dateLabel(int index) { if (meals.isEmpty) return null; final meal = meals[index.clamp(0, meals.length - 1)]; @@ -156,7 +189,11 @@ class _MealHeader extends StatelessWidget { final parsed = DateTime.tryParse(meal.date); if (parsed == null) return meal.date; - return '${DateFormat('M월 d일', 'ko').format(parsed)}(${meal.dayOfWeek})'; + const weekdays = ['월', '화', '수', '목', '금', '토', '일']; + final weekday = meal.dayOfWeek.isNotEmpty + ? meal.dayOfWeek + : weekdays[parsed.weekday - 1]; + return '${DateFormat('M월 d일', 'ko').format(parsed)}($weekday)'; } } From 94cc41c74b28c305a18222fed0d911530df01012 Mon Sep 17 00:00:00 2001 From: rdyjun Date: Sun, 6 Sep 2026 00:04:13 +0900 Subject: [PATCH 37/40] =?UTF-8?q?fix:=20=ED=99=88=EC=97=90=EC=84=9C=20?= =?UTF-8?q?=EC=82=AC=EB=9D=BC=EC=A1=8C=EB=8D=98=20=EC=98=A4=EB=8A=98=20?= =?UTF-8?q?=EC=9D=BC=EC=A0=95=EC=9D=84=20=EB=90=98=EC=82=B4=EB=A6=B0?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 재구성하면서 HomeToday 의 일정 카드가 빠져 homeEntity.schedule 을 아무도 읽지 않게 됐다. 특히 비회원은 학사 일정을 볼 곳이 없어졌다. 바로가기의 학사일정은 링크일 뿐 내용을 보여주지 않는다. 수업과 학식 사이에 일정 덱을 넣는다. 표기는 기존 HomeToday 를 따른다. - 학사 일정은 시각 대신 "학사", 개인 일정은 시작 시각 - 비회원에게는 개인 일정이 없으므로 학사 일정만 남긴다 - 칸을 누르면 일정 화면으로 간다 --- lib/presentation/home/home_page_screen.dart | 1 + .../home/widgets/home_today_card.dart | 48 +++++++++++++++++-- 2 files changed, 45 insertions(+), 4 deletions(-) diff --git a/lib/presentation/home/home_page_screen.dart b/lib/presentation/home/home_page_screen.dart index 97cf5d9d..ed9e61ae 100644 --- a/lib/presentation/home/home_page_screen.dart +++ b/lib/presentation/home/home_page_screen.dart @@ -94,6 +94,7 @@ class HomePageScreen extends HookConsumerWidget { ), HomeTodayCard( timeTable: homeEntity.timeTable, + schedule: homeEntity.schedule, isLoggedOut: user == null, ), HomeNoticeList(notices: homeEntity.notices), diff --git a/lib/presentation/home/widgets/home_today_card.dart b/lib/presentation/home/widgets/home_today_card.dart index 651fd7da..2a7183df 100644 --- a/lib/presentation/home/widgets/home_today_card.dart +++ b/lib/presentation/home/widgets/home_today_card.dart @@ -17,11 +17,13 @@ import 'package:intl/intl.dart'; /// 우측 셰브런만 그 사실을 알린다. 제목 줄을 없애면 카드가 한 뼘 짧아진다. class HomeTodayCard extends HookConsumerWidget { final List timeTable; + final List schedule; final bool isLoggedOut; const HomeTodayCard({ super.key, required this.timeTable, + required this.schedule, required this.isLoggedOut, }); @@ -64,11 +66,10 @@ class HomeTodayCard extends HookConsumerWidget { : _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), - ), + _divider(), ], + _scheduleDeck(context), + _divider(), cafeteriaState.when( data: (_) => Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -112,6 +113,45 @@ class HomeTodayCard extends HookConsumerWidget { ); } + Widget _divider() { + return const Padding( + padding: EdgeInsets.fromLTRB(16, 14, 16, 14), + child: Divider(height: 1, thickness: 1, color: ColorStyles.gray2), + ); + } + + /// 오늘 일정. 비회원에게는 개인 일정이 없으므로 학사 일정만 남긴다. + 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 + ? _TodayRow( + emoji: '🗓️', + background: ColorStyles.mintBg, + title: isLoggedOut ? '오늘 학사 일정이 없어요' : '오늘 일정이 없어요', + isMuted: true, + ) + : _scheduleRow(items[index]), + onTapItem: () => context.push(RoutePaths.schedule), + ); + } + + Widget _scheduleRow(Schedule item) { + return _TodayRow( + emoji: '🗓️', + background: ColorStyles.mintBg, + title: item.title, + description: item.type == ScheduleType.official + ? '학사' + : _formatHourMinute(item.startAt), + showChevron: true, + ); + } + int _findTodayMealIndex(List meals) { if (meals.isEmpty) return 0; From cb77b70d2412b757d35d4dace006b5a086774f87 Mon Sep 17 00:00:00 2001 From: rdyjun Date: Sun, 6 Sep 2026 00:11:22 +0900 Subject: [PATCH 38/40] =?UTF-8?q?refactor:=20=ED=99=88=EC=97=90=EC=84=9C?= =?UTF-8?q?=20=ED=95=99=EC=8B=9D=EC=9D=84=20=EB=B3=84=EB=8F=84=20=EA=B5=AC?= =?UTF-8?q?=ED=9A=8D=EC=9C=BC=EB=A1=9C=20=EB=B9=BC=EA=B3=A0=20=EC=9D=B8?= =?UTF-8?q?=EC=82=AC=EB=A7=90=EC=97=90=20=EC=9D=BC=EC=A0=95=EC=9D=84=20?= =?UTF-8?q?=EB=84=A3=EB=8A=94=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 수업·일정은 내 것이고 학식은 학교 것이라 한 장에 묶으면 카드가 무슨 카드인지 흐려진다. 오늘 카드는 수업과 일정만 담고, 학식은 공지 위에 자기 제목을 단 구획으로 세운다. - HomeMealSection 신설. 제목의 날짜가 넘긴 쪽을 따라 바뀌므로 구획이 스스로 상태를 든다. 다른 구획과 같은 largeTextBold 제목을 쓴다 - 두 카드가 같이 쓰는 줄을 HomeTodayRow 로 뽑는다 - 학식이 빠지면서 HomeTodayCard 는 훅도 프로바이더도 필요 없어져 StatelessWidget 이 된다 - 인사말을 수업 + 일정으로 센다. 공지는 서버에서 세 건으로 잘려 와 세면 매일 "새 공지 3개" 가 뜨므로 넣지 않는다 --- lib/presentation/home/home_page_screen.dart | 4 +- .../home/widgets/home_greeting.dart | 39 ++- .../home/widgets/home_meal_section.dart | 141 ++++++++++ .../home/widgets/home_today_card.dart | 248 ++---------------- .../home/widgets/home_today_row.dart | 82 ++++++ 5 files changed, 285 insertions(+), 229 deletions(-) create mode 100644 lib/presentation/home/widgets/home_meal_section.dart create mode 100644 lib/presentation/home/widgets/home_today_row.dart diff --git a/lib/presentation/home/home_page_screen.dart b/lib/presentation/home/home_page_screen.dart index ed9e61ae..2546f6aa 100644 --- a/lib/presentation/home/home_page_screen.dart +++ b/lib/presentation/home/home_page_screen.dart @@ -3,6 +3,7 @@ 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_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'; @@ -87,9 +88,9 @@ class HomePageScreen extends HookConsumerWidget { child: ListView( padding: EdgeInsets.zero, children: [ - // 공지는 세 건으로 잘려 오고 읽음 여부도 몰라 아직 세지 않는다 HomeGreeting( classCount: homeEntity.timeTable.length, + scheduleCount: homeEntity.schedule.length, isLoggedOut: user == null, ), HomeTodayCard( @@ -97,6 +98,7 @@ class HomePageScreen extends HookConsumerWidget { schedule: homeEntity.schedule, isLoggedOut: user == null, ), + const HomeMealSection(), HomeNoticeList(notices: homeEntity.notices), const Padding( padding: EdgeInsets.symmetric(vertical: 22, horizontal: 16), diff --git a/lib/presentation/home/widgets/home_greeting.dart b/lib/presentation/home/widgets/home_greeting.dart index a8b617bb..f6dc5ac9 100644 --- a/lib/presentation/home/widgets/home_greeting.dart +++ b/lib/presentation/home/widgets/home_greeting.dart @@ -3,14 +3,23 @@ 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, }); @@ -42,17 +51,35 @@ class HomeGreeting extends StatelessWidget { if (isLoggedOut) { return const TextSpan(text: '오늘의 캠퍼스 소식'); } - if (classCount == 0) { - 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: [ - const TextSpan(text: '오늘 수업 '), + TextSpan(text: label), TextSpan( - text: '$classCount개', + text: '$count개', style: const TextStyle(color: ColorStyles.primary100), ), - const TextSpan(text: ' 있어요'), ], ); } 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..94526aaa --- /dev/null +++ b/lib/presentation/home/widgets/home_meal_section.dart @@ -0,0 +1,141 @@ +import 'package:dongsoop/domain/cafeteria/entities/cafeteria_entity.dart'; +import 'package:dongsoop/presentation/home/view_models/cafeteria_view_model.dart'; +import 'package:dongsoop/presentation/home/widgets/home_today_row.dart'; +import 'package:dongsoop/presentation/home/widgets/swipe_deck.dart'; +import 'package:dongsoop/ui/color_styles.dart'; +import 'package:dongsoop/ui/text_styles.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:intl/intl.dart'; + +/// 홈의 학식 구획. +/// +/// 오늘 카드에서 떼어내 따로 세웠다. 수업·일정은 내 것이고 학식은 학교 것이라 +/// 한 장에 묶으면 카드가 무슨 카드인지 흐려진다. +/// +/// 제목의 날짜는 넘긴 쪽을 따라 바뀌므로 구획 안에서 상태를 들고 있는다. +class HomeMealSection extends HookConsumerWidget { + const HomeMealSection({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final cafeteriaState = ref.watch(cafeteriaViewModelProvider); + final meals = cafeteriaState.maybeWhen( + data: (data) => data.weekMeals, + orElse: () => const [], + ); + + final todayIndex = _findTodayIndex(meals); + final index = useState(todayIndex); + + useEffect(() { + index.value = todayIndex; + return null; + }, [meals]); + + return Padding( + padding: const EdgeInsets.fromLTRB(20, 22, 20, 0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _title(_dateLabel(meals, index.value)), + const SizedBox(height: 12), + Container( + decoration: BoxDecoration( + color: ColorStyles.gray1, + borderRadius: BorderRadius.circular(20), + ), + padding: const EdgeInsets.symmetric(vertical: 16), + child: cafeteriaState.when( + data: (_) => SwipeDeck( + itemCount: meals.isEmpty ? 1 : meals.length, + initialPage: index.value, + onPageChanged: (page) => index.value = page, + itemBuilder: (context, page) => meals.isEmpty + ? const HomeTodayRow( + emoji: '🍚', + background: ColorStyles.gray2, + title: '이번 주 학식 정보가 없어요', + isMuted: true, + ) + : HomeTodayRow( + emoji: '🍚', + background: ColorStyles.amberBg, + title: meals[page].koreanMenu, + ), + ), + loading: () => const SizedBox( + height: 68, + child: HomeTodayRow( + emoji: '🍚', + background: ColorStyles.gray2, + title: '학식을 불러오는 중...', + isMuted: true, + ), + ), + error: (_, __) => const SizedBox( + height: 68, + child: HomeTodayRow( + emoji: '🍚', + background: ColorStyles.gray2, + title: '학식을 불러오지 못했어요', + isMuted: true, + ), + ), + ), + ), + ], + ), + ); + } + + /// 다른 구획 제목과 같은 크기로 두고, 날짜만 중점 뒤에 흐리게 붙인다. + Widget _title(String? label) { + final style = TextStyles.largeTextBold.copyWith(color: ColorStyles.black); + + if (label == null) return Text('학식', style: style); + + return Text.rich( + TextSpan( + style: style, + children: [ + const TextSpan(text: '학식'), + const TextSpan(text: ' · ', style: TextStyle(color: ColorStyles.gray4)), + TextSpan(text: label, style: const TextStyle(color: ColorStyles.gray6)), + ], + ), + overflow: TextOverflow.ellipsis, + ); + } + + int _findTodayIndex(List meals) { + if (meals.isEmpty) return 0; + + final now = DateTime.now(); + final index = meals.indexWhere((meal) { + final date = DateTime.tryParse(meal.date); + if (date == null) return false; + + return date.year == now.year && + date.month == now.month && + date.day == now.day; + }); + + return index >= 0 ? index : 0; + } + + /// 메뉴가 없는 날은 서버 요일이 비어 있으므로 날짜에서 직접 구한다. + String? _dateLabel(List meals, int index) { + if (meals.isEmpty) return null; + final meal = meals[index.clamp(0, meals.length - 1)]; + + final parsed = DateTime.tryParse(meal.date); + if (parsed == null) return meal.date; + + const weekdays = ['월', '화', '수', '목', '금', '토', '일']; + final weekday = + meal.dayOfWeek.isNotEmpty ? meal.dayOfWeek : weekdays[parsed.weekday - 1]; + return '${DateFormat('M월 d일', 'ko').format(parsed)}($weekday)'; + } +} diff --git a/lib/presentation/home/widgets/home_today_card.dart b/lib/presentation/home/widgets/home_today_card.dart index 2a7183df..38073b20 100644 --- a/lib/presentation/home/widgets/home_today_card.dart +++ b/lib/presentation/home/widgets/home_today_card.dart @@ -1,21 +1,19 @@ import 'package:dongsoop/core/routing/route_paths.dart'; -import 'package:dongsoop/domain/cafeteria/entities/cafeteria_entity.dart'; import 'package:dongsoop/domain/home/entity/home_entity.dart'; -import 'package:dongsoop/presentation/home/view_models/cafeteria_view_model.dart'; +import 'package:dongsoop/presentation/home/widgets/home_today_row.dart'; import 'package:dongsoop/presentation/home/widgets/swipe_deck.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:flutter_hooks/flutter_hooks.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:intl/intl.dart'; -/// 오늘 챙길 것 한 장 — 수업과 학식. +/// 오늘 챙길 것 한 장 — 수업과 일정. /// -/// `오늘` 제목과 `시간표 ›` 버튼은 두지 않는다. 수업 칸 자체가 시간표로 가는 링크이고 +/// `오늘` 제목과 `시간표 ›` 버튼은 두지 않는다. 각 칸이 그대로 링크이고 /// 우측 셰브런만 그 사실을 알린다. 제목 줄을 없애면 카드가 한 뼘 짧아진다. -class HomeTodayCard extends HookConsumerWidget { +/// +/// 학식은 `HomeMealSection` 으로 따로 뺐다. 수업·일정은 내 것이고 학식은 +/// 학교 것이라, 한 장에 묶으면 이 카드가 무슨 카드인지 흐려진다. +class HomeTodayCard extends StatelessWidget { final List timeTable; final List schedule; final bool isLoggedOut; @@ -28,20 +26,7 @@ class HomeTodayCard extends HookConsumerWidget { }); @override - Widget build(BuildContext context, WidgetRef ref) { - final cafeteriaState = ref.watch(cafeteriaViewModelProvider); - final meals = cafeteriaState.maybeWhen( - data: (data) => data.weekMeals, - orElse: () => const [], - ); - final todayMealIndex = _findTodayMealIndex(meals); - final mealIndex = useState(todayMealIndex); - - useEffect(() { - mealIndex.value = todayMealIndex; - return null; - }, [meals]); - + Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.fromLTRB(20, 22, 20, 0), child: Container( @@ -57,7 +42,7 @@ class HomeTodayCard extends HookConsumerWidget { SwipeDeck( itemCount: timeTable.isEmpty ? 1 : timeTable.length, itemBuilder: (context, index) => timeTable.isEmpty - ? const _TodayRow( + ? const HomeTodayRow( emoji: '📘', background: ColorStyles.primary5, title: '오늘은 수업이 없어요', @@ -66,60 +51,19 @@ class HomeTodayCard extends HookConsumerWidget { : _classRow(timeTable[index]), onTapItem: () => context.push(RoutePaths.timetable), ), - _divider(), + const Padding( + padding: EdgeInsets.fromLTRB(16, 14, 16, 14), + child: + Divider(height: 1, thickness: 1, color: ColorStyles.gray2), + ), ], _scheduleDeck(context), - _divider(), - cafeteriaState.when( - data: (_) => Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _MealHeader(meals: meals, index: mealIndex.value), - SwipeDeck( - itemCount: meals.isEmpty ? 1 : meals.length, - initialPage: mealIndex.value, - onPageChanged: (index) => mealIndex.value = index, - itemBuilder: (context, index) => meals.isEmpty - ? const _TodayRow( - emoji: '🍚', - background: ColorStyles.gray2, - title: '이번 주 학식 정보가 없어요', - isMuted: true, - ) - : _TodayRow( - emoji: '🍚', - background: ColorStyles.amberBg, - title: meals[index].koreanMenu, - ), - ), - ], - ), - loading: () => const _TodayRow( - emoji: '🍚', - background: ColorStyles.gray2, - title: '학식을 불러오는 중...', - isMuted: true, - ), - error: (_, __) => const _TodayRow( - emoji: '🍚', - background: ColorStyles.gray2, - title: '학식을 불러오지 못했어요', - isMuted: true, - ), - ), ], ), ), ); } - Widget _divider() { - return const Padding( - padding: EdgeInsets.fromLTRB(16, 14, 16, 14), - child: Divider(height: 1, thickness: 1, color: ColorStyles.gray2), - ); - } - /// 오늘 일정. 비회원에게는 개인 일정이 없으므로 학사 일정만 남긴다. Widget _scheduleDeck(BuildContext context) { final items = isLoggedOut @@ -129,7 +73,7 @@ class HomeTodayCard extends HookConsumerWidget { return SwipeDeck( itemCount: items.isEmpty ? 1 : items.length, itemBuilder: (context, index) => items.isEmpty - ? _TodayRow( + ? HomeTodayRow( emoji: '🗓️', background: ColorStyles.mintBg, title: isLoggedOut ? '오늘 학사 일정이 없어요' : '오늘 일정이 없어요', @@ -141,173 +85,33 @@ class HomeTodayCard extends HookConsumerWidget { } Widget _scheduleRow(Schedule item) { - return _TodayRow( + return HomeTodayRow( emoji: '🗓️', background: ColorStyles.mintBg, title: item.title, description: item.type == ScheduleType.official ? '학사' - : _formatHourMinute(item.startAt), + : formatHourMinute(item.startAt), showChevron: true, ); } - int _findTodayMealIndex(List meals) { - if (meals.isEmpty) return 0; - - final now = DateTime.now(); - final index = meals.indexWhere((meal) { - final date = DateTime.tryParse(meal.date); - if (date == null) return false; - - return date.year == now.year && - date.month == now.month && - date.day == now.day; - }); - - return index >= 0 ? index : 0; - } - Widget _classRow(Slot slot) { - return _TodayRow( + return HomeTodayRow( emoji: '📘', background: ColorStyles.primary5, title: slot.title, - description: '${_formatHourMinute(slot.startAt)} - ${_formatHourMinute(slot.endAt)}', + 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)!}'; - } -} - -/// 학식 제목. 넘긴 날짜를 따라 `학식 · 9월 5일(금)` 로 바뀐다. -class _MealHeader extends StatelessWidget { - final List meals; - final int index; - - const _MealHeader({required this.meals, required this.index}); - - @override - Widget build(BuildContext context) { - final label = _dateLabel(index); - return Padding( - padding: const EdgeInsets.fromLTRB(16, 0, 16, 10), - child: Text.rich( - TextSpan( - style: TextStyles.normalTextBold.copyWith( - color: ColorStyles.black, - ), - children: [ - const TextSpan(text: '학식'), - if (label != null) ...[ - const TextSpan( - text: ' · ', - style: TextStyle(color: ColorStyles.gray4), - ), - TextSpan( - text: label, - style: const TextStyle(color: ColorStyles.gray6), - ), - ], - ], - ), - ), - ); - } - - /// 메뉴가 없는 날은 서버 요일이 비어 있으므로 기존 요일 표기를 사용한다. - String? _dateLabel(int index) { - if (meals.isEmpty) return null; - final meal = meals[index.clamp(0, meals.length - 1)]; - - final parsed = DateTime.tryParse(meal.date); - if (parsed == null) return meal.date; - - const weekdays = ['월', '화', '수', '목', '금', '토', '일']; - final weekday = meal.dayOfWeek.isNotEmpty - ? meal.dayOfWeek - : weekdays[parsed.weekday - 1]; - return '${DateFormat('M월 d일', 'ko').format(parsed)}($weekday)'; - } } -class _TodayRow extends StatelessWidget { - final String emoji; - final Color background; - final String title; - final String? description; - final bool showChevron; - - /// 비어 있음을 알리는 줄은 흐리게 둔다 - final bool isMuted; - - const _TodayRow({ - 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, - ), - ], - ], - ), - ); - } +/// 기존 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, + ), + ], + ], + ), + ); + } +} From 5f67d7bddc3036cb902013189b2d69c8627dcccc Mon Sep 17 00:00:00 2001 From: rdyjun Date: Sun, 6 Sep 2026 00:22:10 +0900 Subject: [PATCH 39/40] =?UTF-8?q?design:=20=ED=95=99=EC=8B=9D=20=EA=B5=AC?= =?UTF-8?q?=ED=9A=8D=EC=9D=84=20=EC=98=A4=EB=8A=98=20=EC=B9=B4=EB=93=9C?= =?UTF-8?q?=EC=99=80=20=EB=8B=A4=EB=A5=B8=20=EC=83=9D=EA=B9=80=EC=83=88?= =?UTF-8?q?=EB=A1=9C=20=EA=B0=80=EB=A5=B8=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 두 구획이 gray1 채움에 같은 줄 위젯, 같은 높이라 스크롤하면 같은 카드가 두 번 나오는 것처럼 보였다. - 면을 amberBg 로 바꾼다. 오늘 카드가 회색이라 곧바로 갈린다 - 40x40 이모지 타일을 뺀다. 구획 제목이 이미 학식이라 타일이 같은 말을 한 번 더 하고 있었고, 홈에서 타일 반복이 셋에서 둘로 준다 - 메뉴를 흰 칩으로 편다. 한 줄에 밀어 넣으면 읽어야 할 덩어리가 된다 - 덱 높이 68 -> 92. 오늘 카드와 비율도 갈린다 - 불러오는 중·실패·자료 없음은 펼칠 메뉴가 없으니 중립 회색 낮은 칸 서버 구분자를 확정할 수 없어 쓰일 만한 기호를 모두 끊고, 끊을 곳이 없는 문장(주말·휴일 안내)은 칩으로 만들지 않는다. 반찬이 아주 많은 날은 칩이 세 줄을 넘길 수 있어 ClipRect 로 잘라 낸다. PageView 는 장마다 높이를 달리 줄 수 없다. 새 색은 쓰지 않았다. amberBg 는 원래 이 구획 이모지 타일 배경이었다. --- .../home/widgets/home_meal_section.dart | 158 +++++++++++++----- 1 file changed, 114 insertions(+), 44 deletions(-) diff --git a/lib/presentation/home/widgets/home_meal_section.dart b/lib/presentation/home/widgets/home_meal_section.dart index 94526aaa..ceabbc39 100644 --- a/lib/presentation/home/widgets/home_meal_section.dart +++ b/lib/presentation/home/widgets/home_meal_section.dart @@ -1,6 +1,5 @@ import 'package:dongsoop/domain/cafeteria/entities/cafeteria_entity.dart'; import 'package:dongsoop/presentation/home/view_models/cafeteria_view_model.dart'; -import 'package:dongsoop/presentation/home/widgets/home_today_row.dart'; import 'package:dongsoop/presentation/home/widgets/swipe_deck.dart'; import 'package:dongsoop/ui/color_styles.dart'; import 'package:dongsoop/ui/text_styles.dart'; @@ -14,10 +13,17 @@ import 'package:intl/intl.dart'; /// 오늘 카드에서 떼어내 따로 세웠다. 수업·일정은 내 것이고 학식은 학교 것이라 /// 한 장에 묶으면 카드가 무슨 카드인지 흐려진다. /// +/// 생김새도 오늘 카드와 갈라 둔다. 오늘 카드는 회색 면에 `40x40 이모지 + 한 줄` +/// 이지만 여기는 따뜻한 면에 이모지 타일 없이 메뉴만 칩으로 편다. 구획 제목이 +/// 이미 `학식` 이라 타일이 한 번 더 그걸 말할 이유가 없다. +/// /// 제목의 날짜는 넘긴 쪽을 따라 바뀌므로 구획 안에서 상태를 들고 있는다. class HomeMealSection extends HookConsumerWidget { const HomeMealSection({super.key}); + /// 메뉴 칩이 세 줄까지 들어가는 높이. 칩 한 줄 24.4 + 줄 간격 7. + static const double _deckHeight = 92; + @override Widget build(BuildContext context, WidgetRef ref) { final cafeteriaState = ref.watch(cafeteriaViewModelProvider); @@ -41,49 +47,21 @@ class HomeMealSection extends HookConsumerWidget { children: [ _title(_dateLabel(meals, index.value)), const SizedBox(height: 12), - Container( - decoration: BoxDecoration( - color: ColorStyles.gray1, - borderRadius: BorderRadius.circular(20), - ), - padding: const EdgeInsets.symmetric(vertical: 16), - child: cafeteriaState.when( - data: (_) => SwipeDeck( - itemCount: meals.isEmpty ? 1 : meals.length, - initialPage: index.value, - onPageChanged: (page) => index.value = page, - itemBuilder: (context, page) => meals.isEmpty - ? const HomeTodayRow( - emoji: '🍚', - background: ColorStyles.gray2, - title: '이번 주 학식 정보가 없어요', - isMuted: true, - ) - : HomeTodayRow( - emoji: '🍚', - background: ColorStyles.amberBg, - title: meals[page].koreanMenu, - ), - ), - loading: () => const SizedBox( - height: 68, - child: HomeTodayRow( - emoji: '🍚', - background: ColorStyles.gray2, - title: '학식을 불러오는 중...', - isMuted: true, - ), - ), - error: (_, __) => const SizedBox( - height: 68, - child: HomeTodayRow( - emoji: '🍚', - background: ColorStyles.gray2, - title: '학식을 불러오지 못했어요', - isMuted: true, - ), - ), - ), + cafeteriaState.when( + data: (_) => meals.isEmpty + ? _notice('이번 주 학식 정보가 없어요') + : _tray( + SwipeDeck( + itemCount: meals.length, + initialPage: index.value, + height: _deckHeight, + onPageChanged: (page) => index.value = page, + itemBuilder: (context, page) => + _MealMenu(menu: meals[page].koreanMenu), + ), + ), + loading: () => _notice('학식을 불러오는 중...'), + error: (_, __) => _notice('학식을 불러오지 못했어요'), ), ], ), @@ -109,6 +87,35 @@ class HomeMealSection extends HookConsumerWidget { ); } + /// 학식 판. 요일을 넘겨도 면 색이 깜빡이지 않도록 휴무일까지 같은 색으로 둔다. + Widget _tray(Widget child) { + return Container( + decoration: BoxDecoration( + color: ColorStyles.amberBg, + borderRadius: BorderRadius.circular(20), + ), + padding: const EdgeInsets.symmetric(vertical: 16), + child: child, + ); + } + + /// 불러오는 중·실패·이번 주 자료 없음. 펼칠 메뉴가 없으니 따뜻한 면 대신 + /// 중립 회색으로 두고 높이도 낮춘다. + Widget _notice(String message) { + return Container( + decoration: BoxDecoration( + color: ColorStyles.gray1, + borderRadius: BorderRadius.circular(20), + ), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 20), + alignment: Alignment.centerLeft, + child: Text( + message, + style: TextStyles.normalTextBold.copyWith(color: ColorStyles.gray4), + ), + ); + } + int _findTodayIndex(List meals) { if (meals.isEmpty) return 0; @@ -139,3 +146,66 @@ class HomeMealSection extends HookConsumerWidget { return '${DateFormat('M월 d일', 'ko').format(parsed)}($weekday)'; } } + +/// 하루치 메뉴. +/// +/// 한 줄에 밀어 넣으면 `제육볶음 · 미역국 · 계란찜` 처럼 읽어야 할 덩어리가 +/// 되므로 반찬 하나를 칩 하나로 끊어 편다. 어느 메뉴가 앞에 올지 서버가 +/// 보장하지 않으니 전부 같은 무게로 둔다. +class _MealMenu extends StatelessWidget { + final String menu; + + const _MealMenu({required this.menu}); + + @override + Widget build(BuildContext context) { + final items = _split(menu); + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: items.length < 2 + // 휴무일 안내처럼 끊을 곳이 없는 문장은 칩으로 만들지 않는다 + ? Text( + items.isEmpty ? '등록된 메뉴가 없어요' : items.first, + style: + TextStyles.normalTextBold.copyWith(color: ColorStyles.gray6), + ) + // 반찬이 아주 많은 날은 칩이 세 줄을 넘길 수 있다. PageView 는 장마다 + // 높이를 달리 못 주므로 넘치는 줄은 잘라 낸다. 오버플로 경고 대신 + // 조용히 잘리는 편이 낫고, 전체 메뉴는 학식 화면에서 볼 수 있다 + : ClipRect( + child: Wrap( + spacing: 6, + runSpacing: 7, + children: [for (final item in items) _chip(item)], + ), + ), + ); + } + + Widget _chip(String label) { + return Container( + decoration: BoxDecoration( + color: ColorStyles.white, + borderRadius: BorderRadius.circular(10), + ), + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), + child: Text( + label, + style: TextStyles.smallTextBold.copyWith( + color: ColorStyles.black, + height: 1.2, + ), + ), + ); + } + + /// 서버가 구분자를 섞어 내려주므로 쓰이는 기호를 모두 끊어 본다. + static List _split(String raw) { + return raw + .split(RegExp(r'[·•,/\n]')) + .map((item) => item.trim()) + .where((item) => item.isNotEmpty) + .toList(growable: false); + } +} From 4de39ca7592e031a1972785aff67b31049d5de37 Mon Sep 17 00:00:00 2001 From: rdyjun Date: Sun, 6 Sep 2026 11:47:54 +0900 Subject: [PATCH 40/40] =?UTF-8?q?design:=20=ED=99=88=20=ED=95=99=EC=8B=9D?= =?UTF-8?q?=EC=9D=84=20=EC=BA=A0=ED=8D=BC=EC=8A=A4=EC=99=80=20=EA=B0=99?= =?UTF-8?q?=EC=9D=80=20=ED=8C=90=EC=9C=BC=EB=A1=9C=20=EB=A7=9E=EC=B6=98?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 캠퍼스에서 만든 MealDeck 을 홈도 그대로 쓴다. 두 화면이 같은 모양으로 읽히고 같이 좌우로 넘어간다. 홈에서만 요일을 못 넘기게 두면 예전에 되던 것이 안 되는 셈이라 회귀다. 다른 것은 그릇뿐이다. 홈은 채운 면 대신 왼쪽 세로선을 쓴다 — 바로 위 오늘 카드가 이미 둥근 회색 면이라, 학식까지 면을 깔면 색만 다른 같은 덩어리 둘이 붙어 있게 된다. 앰버 면과 흰 칩으로 갈라 보려 했던 것을 걷어냈다. 겹치는 이유가 색이 아니라 구조여서 색으로는 갈라지지 않는다. SwipeDeck 을 core/presentation/components 로 옮겼다. 홈 오늘 카드와 캠퍼스 학식이 같이 쓰는데 presentation/home/widgets 에 두면 캠퍼스가 홈 안쪽을 들여다보게 된다. --- .../home/widgets/home_meal_section.dart | 222 ++++-------------- .../home/widgets/home_today_card.dart | 2 +- lib/presentation/home/widgets/swipe_deck.dart | 136 ----------- 3 files changed, 50 insertions(+), 310 deletions(-) delete mode 100644 lib/presentation/home/widgets/swipe_deck.dart diff --git a/lib/presentation/home/widgets/home_meal_section.dart b/lib/presentation/home/widgets/home_meal_section.dart index ceabbc39..ce62e620 100644 --- a/lib/presentation/home/widgets/home_meal_section.dart +++ b/lib/presentation/home/widgets/home_meal_section.dart @@ -1,211 +1,87 @@ -import 'package:dongsoop/domain/cafeteria/entities/cafeteria_entity.dart'; +import 'package:dongsoop/core/presentation/components/meal_menu_view.dart'; import 'package:dongsoop/presentation/home/view_models/cafeteria_view_model.dart'; -import 'package:dongsoop/presentation/home/widgets/swipe_deck.dart'; import 'package:dongsoop/ui/color_styles.dart'; import 'package:dongsoop/ui/text_styles.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:intl/intl.dart'; /// 홈의 학식 구획. /// -/// 오늘 카드에서 떼어내 따로 세웠다. 수업·일정은 내 것이고 학식은 학교 것이라 +/// 오늘 카드에서 떼어 따로 세웠다. 수업·일정은 내 것이고 학식은 학교 것이라 /// 한 장에 묶으면 카드가 무슨 카드인지 흐려진다. /// -/// 생김새도 오늘 카드와 갈라 둔다. 오늘 카드는 회색 면에 `40x40 이모지 + 한 줄` -/// 이지만 여기는 따뜻한 면에 이모지 타일 없이 메뉴만 칩으로 편다. 구획 제목이 -/// 이미 `학식` 이라 타일이 한 번 더 그걸 말할 이유가 없다. -/// -/// 제목의 날짜는 넘긴 쪽을 따라 바뀌므로 구획 안에서 상태를 들고 있는다. -class HomeMealSection extends HookConsumerWidget { +/// 캠퍼스 학식과 같은 조각(`MealDeck`)을 써서 같은 모양으로 읽히고 같이 +/// 좌우로 넘어간다. 다른 것은 그릇뿐이다 — 여기는 채운 면 대신 왼쪽 세로선을 +/// 쓴다. 바로 위 오늘 카드가 이미 둥근 회색 면이라, 학식까지 면을 깔면 색만 +/// 다른 같은 덩어리 둘이 붙어 있게 된다. +class HomeMealSection extends ConsumerWidget { const HomeMealSection({super.key}); - /// 메뉴 칩이 세 줄까지 들어가는 높이. 칩 한 줄 24.4 + 줄 간격 7. - static const double _deckHeight = 92; - @override Widget build(BuildContext context, WidgetRef ref) { - final cafeteriaState = ref.watch(cafeteriaViewModelProvider); - final meals = cafeteriaState.maybeWhen( - data: (data) => data.weekMeals, - orElse: () => const [], - ); - - final todayIndex = _findTodayIndex(meals); - final index = useState(todayIndex); - - useEffect(() { - index.value = todayIndex; - return null; - }, [meals]); + final state = ref.watch(cafeteriaViewModelProvider); return Padding( padding: const EdgeInsets.fromLTRB(20, 22, 20, 0), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - _title(_dateLabel(meals, index.value)), + Text( + '학식', + style: TextStyles.largeTextBold.copyWith(color: ColorStyles.black), + ), const SizedBox(height: 12), - cafeteriaState.when( - data: (_) => meals.isEmpty - ? _notice('이번 주 학식 정보가 없어요') - : _tray( - SwipeDeck( - itemCount: meals.length, - initialPage: index.value, - height: _deckHeight, - onPageChanged: (page) => index.value = page, - itemBuilder: (context, page) => - _MealMenu(menu: meals[page].koreanMenu), - ), - ), - loading: () => _notice('학식을 불러오는 중...'), - error: (_, __) => _notice('학식을 불러오지 못했어요'), + 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('학식을 불러오지 못했어요'), + ), ), ], ), ); } - - /// 다른 구획 제목과 같은 크기로 두고, 날짜만 중점 뒤에 흐리게 붙인다. - Widget _title(String? label) { - final style = TextStyles.largeTextBold.copyWith(color: ColorStyles.black); - - if (label == null) return Text('학식', style: style); - - return Text.rich( - TextSpan( - style: style, - children: [ - const TextSpan(text: '학식'), - const TextSpan(text: ' · ', style: TextStyle(color: ColorStyles.gray4)), - TextSpan(text: label, style: const TextStyle(color: ColorStyles.gray6)), - ], - ), - overflow: TextOverflow.ellipsis, - ); - } - - /// 학식 판. 요일을 넘겨도 면 색이 깜빡이지 않도록 휴무일까지 같은 색으로 둔다. - Widget _tray(Widget child) { - return Container( - decoration: BoxDecoration( - color: ColorStyles.amberBg, - borderRadius: BorderRadius.circular(20), - ), - padding: const EdgeInsets.symmetric(vertical: 16), - child: child, - ); - } - - /// 불러오는 중·실패·이번 주 자료 없음. 펼칠 메뉴가 없으니 따뜻한 면 대신 - /// 중립 회색으로 두고 높이도 낮춘다. - Widget _notice(String message) { - return Container( - decoration: BoxDecoration( - color: ColorStyles.gray1, - borderRadius: BorderRadius.circular(20), - ), - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 20), - alignment: Alignment.centerLeft, - child: Text( - message, - style: TextStyles.normalTextBold.copyWith(color: ColorStyles.gray4), - ), - ); - } - - int _findTodayIndex(List meals) { - if (meals.isEmpty) return 0; - - final now = DateTime.now(); - final index = meals.indexWhere((meal) { - final date = DateTime.tryParse(meal.date); - if (date == null) return false; - - return date.year == now.year && - date.month == now.month && - date.day == now.day; - }); - - return index >= 0 ? index : 0; - } - - /// 메뉴가 없는 날은 서버 요일이 비어 있으므로 날짜에서 직접 구한다. - String? _dateLabel(List meals, int index) { - if (meals.isEmpty) return null; - final meal = meals[index.clamp(0, meals.length - 1)]; - - final parsed = DateTime.tryParse(meal.date); - if (parsed == null) return meal.date; - - const weekdays = ['월', '화', '수', '목', '금', '토', '일']; - final weekday = - meal.dayOfWeek.isNotEmpty ? meal.dayOfWeek : weekdays[parsed.weekday - 1]; - return '${DateFormat('M월 d일', 'ko').format(parsed)}($weekday)'; - } } -/// 하루치 메뉴. +/// 왼쪽 세로선 + 본문. /// -/// 한 줄에 밀어 넣으면 `제육볶음 · 미역국 · 계란찜` 처럼 읽어야 할 덩어리가 -/// 되므로 반찬 하나를 칩 하나로 끊어 편다. 어느 메뉴가 앞에 올지 서버가 -/// 보장하지 않으니 전부 같은 무게로 둔다. -class _MealMenu extends StatelessWidget { - final String menu; +/// 선은 회색이다. 오늘 카드와 갈라 보이려고 한때 따뜻한 색을 썼는데, 갈라 +/// 주는 건 색이 아니라 면을 쓰지 않는다는 사실이라 색이 할 일이 없다. +class _MealFrame extends StatelessWidget { + final Widget child; + + /// 보여줄 메뉴가 없는 상태에서는 선도 함께 물러난다 + final bool muted; - const _MealMenu({required this.menu}); + const _MealFrame({required this.child, this.muted = false}); @override Widget build(BuildContext context) { - final items = _split(menu); - - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: items.length < 2 - // 휴무일 안내처럼 끊을 곳이 없는 문장은 칩으로 만들지 않는다 - ? Text( - items.isEmpty ? '등록된 메뉴가 없어요' : items.first, - style: - TextStyles.normalTextBold.copyWith(color: ColorStyles.gray6), - ) - // 반찬이 아주 많은 날은 칩이 세 줄을 넘길 수 있다. PageView 는 장마다 - // 높이를 달리 못 주므로 넘치는 줄은 잘라 낸다. 오버플로 경고 대신 - // 조용히 잘리는 편이 낫고, 전체 메뉴는 학식 화면에서 볼 수 있다 - : ClipRect( - child: Wrap( - spacing: 6, - runSpacing: 7, - children: [for (final item in items) _chip(item)], - ), + return IntrinsicHeight( + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Container( + width: 3, + decoration: BoxDecoration( + color: muted ? ColorStyles.gray1 : ColorStyles.gray2, + borderRadius: BorderRadius.circular(2), ), - ); - } - - Widget _chip(String label) { - return Container( - decoration: BoxDecoration( - color: ColorStyles.white, - borderRadius: BorderRadius.circular(10), - ), - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), - child: Text( - label, - style: TextStyles.smallTextBold.copyWith( - color: ColorStyles.black, - height: 1.2, - ), + ), + const SizedBox(width: 13), + Expanded(child: child), + ], ), ); } - - /// 서버가 구분자를 섞어 내려주므로 쓰이는 기호를 모두 끊어 본다. - static List _split(String raw) { - return raw - .split(RegExp(r'[·•,/\n]')) - .map((item) => item.trim()) - .where((item) => item.isNotEmpty) - .toList(growable: false); - } } diff --git a/lib/presentation/home/widgets/home_today_card.dart b/lib/presentation/home/widgets/home_today_card.dart index 38073b20..62db0ba6 100644 --- a/lib/presentation/home/widgets/home_today_card.dart +++ b/lib/presentation/home/widgets/home_today_card.dart @@ -1,7 +1,7 @@ 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/presentation/home/widgets/swipe_deck.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'; diff --git a/lib/presentation/home/widgets/swipe_deck.dart b/lib/presentation/home/widgets/swipe_deck.dart deleted file mode 100644 index 03932fc9..00000000 --- a/lib/presentation/home/widgets/swipe_deck.dart +++ /dev/null @@ -1,136 +0,0 @@ -import 'package:dongsoop/ui/color_styles.dart'; -import 'package:flutter/material.dart'; - -/// 좌우로 넘기는 카드 한 줄과 인디케이터. -/// -/// 넘길 게 하나뿐이어도 끌리도록 [BouncingScrollPhysics] 를 쓴다. -/// 항목이 많을 때는 점을 전부 늘어놓지 않고 현재 위치만 숫자로 보여준다. -class SwipeDeck extends StatefulWidget { - final int itemCount; - final IndexedWidgetBuilder itemBuilder; - final ValueChanged? onPageChanged; - final VoidCallback? onTapItem; - final int initialPage; - final double height; - - const SwipeDeck({ - super.key, - required this.itemCount, - required this.itemBuilder, - this.onPageChanged, - this.onTapItem, - this.initialPage = 0, - this.height = 68, - }); - - @override - State createState() => _SwipeDeckState(); -} - -class _SwipeDeckState extends State { - late final PageController _controller; - late int _index; - - @override - void initState() { - super.initState(); - _index = _normalizePage(widget.initialPage); - _controller = PageController(initialPage: _index); - } - - @override - void didUpdateWidget(covariant SwipeDeck oldWidget) { - super.didUpdateWidget(oldWidget); - - final initialPageChanged = widget.initialPage != oldWidget.initialPage; - final currentIsInvalid = _index >= widget.itemCount; - - if (!initialPageChanged && !currentIsInvalid) return; - - final target = _normalizePage( - initialPageChanged ? widget.initialPage : _index, - ); - - if (target == _index && !currentIsInvalid) return; - - _index = target; - WidgetsBinding.instance.addPostFrameCallback((_) { - if (!mounted || !_controller.hasClients) return; - _controller.jumpToPage(target); - }); - } - - int _normalizePage(int page) { - if (widget.itemCount <= 0) return 0; - return page.clamp(0, widget.itemCount - 1); - } - - @override - void dispose() { - _controller.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return Column( - children: [ - SizedBox( - height: widget.height, - child: PageView.builder( - controller: _controller, - physics: const BouncingScrollPhysics(), - itemCount: widget.itemCount, - onPageChanged: (index) { - setState(() => _index = index); - widget.onPageChanged?.call(index); - }, - itemBuilder: (context, index) { - final child = Align( - alignment: Alignment.centerLeft, - child: widget.itemBuilder(context, index), - ); - - if (widget.onTapItem == null) return child; - - return GestureDetector( - behavior: HitTestBehavior.opaque, - onTap: widget.onTapItem, - child: child, - ); - }, - ), - ), - if (widget.itemCount > 1) ...[ - const SizedBox(height: 10), - if (widget.itemCount <= 7) - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: List.generate(widget.itemCount, (i) { - final isCurrent = i == _index; - return AnimatedContainer( - duration: const Duration(milliseconds: 200), - margin: const EdgeInsets.symmetric(horizontal: 2.5), - width: isCurrent ? 14 : 5, - height: 5, - decoration: BoxDecoration( - color: isCurrent ? ColorStyles.gray5 : ColorStyles.gray2, - borderRadius: BorderRadius.circular(3), - ), - ); - }), - ) - else - Text( - '${_index + 1} / ${widget.itemCount}', - style: const TextStyle( - fontSize: 11, - color: ColorStyles.gray5, - height: 1.2, - ), - ), - ], - ], - ); - } -}