diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index 4d6b499..d85cc62 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -12,12 +12,15 @@ import 'package:bobmoo/models/meal_widget_data.dart'; import 'package:bobmoo/screens/settings_screen.dart'; import 'package:bobmoo/services/permission_service.dart'; import 'package:bobmoo/services/widget_service.dart'; +import 'package:bobmoo/ui/theme/app_typography.dart'; import 'package:bobmoo/utils/meal_utils.dart'; -import 'package:bobmoo/widgets/time_grouped_card.dart'; +import 'package:bobmoo/ui/components/cards/time_grouped_card.dart'; import 'package:bobmoo/utils/hours_parser.dart'; +import 'package:flutter/cupertino.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:flutter_svg/svg.dart'; import 'package:in_app_update/in_app_update.dart'; import 'package:intl/intl.dart'; import 'package:provider/provider.dart'; @@ -402,82 +405,67 @@ class _HomeScreenState extends State with WidgetsBindingObserver { } Widget _buildEmptyState() { - final Color univColor = context.watch().univColor; - - return Center( - child: Padding( - padding: EdgeInsets.all(32.w), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - // 아이콘 - Container( - padding: EdgeInsets.all(24.w), - decoration: BoxDecoration( - color: univColor.withValues(alpha: 0.1), - shape: BoxShape.circle, - ), - child: Icon( - Icons.restaurant_menu, - size: 48.w, - color: univColor, - ), + return Container( + width: double.infinity, + margin: EdgeInsets.all(24.w), + decoration: BoxDecoration( + color: AppColors.colorWhite, + borderRadius: BorderRadius.circular(15.r), + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + // 아이콘 + SvgPicture.asset( + 'assets/icons/icon_bob.svg', + width: 60.w, + ), + SizedBox(height: 24.h), + // 제목 + Text( + '등록된 식단이 없어요', + style: AppTypography.head.sb18, + ), + SizedBox(height: 21.h), + // 설명 + Text( + '식단 정보가 등록되지 않았어요.', + textAlign: TextAlign.center, + style: AppTypography.search.sb15.copyWith( + color: AppColors.colorGray3, ), - SizedBox(height: 24.h), - // 제목 - Text( - '등록된 식단이 없어요', - style: TextStyle( - fontSize: 18.sp, - fontWeight: FontWeight.w600, - color: Colors.black87, - ), + ), + SizedBox( + height: 4.h, + ), + Text( + '잠시 후 다시 확인해주세요.', + textAlign: TextAlign.center, + style: AppTypography.search.sb15.copyWith( + color: AppColors.colorGray3, ), - SizedBox(height: 8.h), - // 설명 - Text( - '아직 오늘의 메뉴가 등록되지 않았습니다.\n잠시 후 다시 확인해주세요.', - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 14.sp, - color: AppColors.greyTextColor, - height: 1.5, + ), + SizedBox(height: 118.h), + // 아래로 당겨 새로고침 + Column( + children: [ + Icon( + Icons.arrow_downward, + color: AppColors.colorGray3, + size: 32.w, ), - ), - SizedBox(height: 24.h), - // 새로고침 버튼 - TextButton( - onPressed: () => setState(() { - _refreshMeals(); - }), - style: TextButton.styleFrom( - foregroundColor: Colors.white, - backgroundColor: univColor, - padding: EdgeInsets.symmetric( - horizontal: 24.w, - vertical: 12.h, - ), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(24.r), - ), + SizedBox( + height: 7.h, ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(Icons.refresh, size: 18.w), - SizedBox(width: 8.w), - Text( - '새로고침', - style: TextStyle( - fontSize: 14.sp, - fontWeight: FontWeight.w600, - ), - ), - ], + Text( + "아래로 당겨 새로고침", + style: AppTypography.button.sb11.copyWith( + color: AppColors.colorGray3, + ), ), - ), - ], - ), + ], + ), + ], ), ); } @@ -487,14 +475,14 @@ class _HomeScreenState extends State with WidgetsBindingObserver { final groupedMeals = groupMeals(meals); final mealTypes = _orderedMealTypesByDynamicHours(groupedMeals); - return RefreshIndicator( - onRefresh: _refreshMeals, // 당겨서 새로고침 기능 연결 - child: ListView.builder( + return SliverPadding( + padding: EdgeInsets.symmetric( + horizontal: 21.w, + vertical: 23.h, + ), + sliver: SliverList.builder( itemCount: mealTypes.length, - padding: EdgeInsets.symmetric( - horizontal: 21.w, - vertical: 23.h, - ), + itemBuilder: (context, index) { final mealType = mealTypes[index]; final mealsByCafeteria = groupedMeals[mealType]; @@ -519,31 +507,64 @@ class _HomeScreenState extends State with WidgetsBindingObserver { return FutureBuilder>( future: _mealFuture, builder: (context, snapshot) { - // 로딩 중 - if (snapshot.connectionState == ConnectionState.waiting) { - return const Center(child: CircularProgressIndicator()); - } - // 에러 발생 - if (snapshot.hasError) { - return _buildErrorWidget(snapshot.error!); - } - // 데이터 없을 시 비어있음 표시 - if (!snapshot.hasData || snapshot.data!.isEmpty) { - return _buildEmptyState(); - } - - // 데이터 로딩 성공 -> MealList 위젯 생성 - return _buildMealList(snapshot.data!); + return CustomScrollView( + physics: const BouncingScrollPhysics( + parent: AlwaysScrollableScrollPhysics(), + ), + slivers: [ + CupertinoSliverRefreshControl( + onRefresh: _refreshMeals, + builder: + ( + context, + refreshState, + pulledExtent, + refreshTriggerPullDistance, + refreshIndicatorExtent, + ) { + return Container( + margin: const EdgeInsets.all(20), + padding: EdgeInsets.all(33.h), + child: CupertinoActivityIndicator( + radius: 10.r, + ), + ); + }, + ), + // 케이스별로 다른 Sliver 추가 + // 로딩 중 + if (snapshot.connectionState == ConnectionState.waiting) + SliverFillRemaining( + hasScrollBody: false, + child: const Center(child: CircularProgressIndicator()), + ) + // 에러 발생 + else if (snapshot.hasError) + SliverFillRemaining( + hasScrollBody: false, + child: _buildErrorWidget(snapshot.error!), + ) + // 데이터 없을 시 비어있음 표시 + else if (!snapshot.hasData || snapshot.data!.isEmpty) + SliverFillRemaining( + hasScrollBody: false, + child: _buildEmptyState(), + ) + // 데이터 로딩 성공 -> MealList 위젯 생성 + else + _buildMealList(snapshot.data!), // Sliver 직접 추가 + ], + ); }, ); } PreferredSizeWidget _buildAppBar() { - final String univName = context.watch().univName; + final UnivProvider univProvider = context.watch(); return AppBar( - toolbarHeight: 103.h, - backgroundColor: Theme.of(context).colorScheme.primary, + toolbarHeight: 140.h, + backgroundColor: univProvider.univColor, shadowColor: Colors.black, elevation: 4.0, surfaceTintColor: Colors.transparent, @@ -552,56 +573,44 @@ class _HomeScreenState extends State with WidgetsBindingObserver { centerTitle: false, // Appbar의 기본 여백 제거 titleSpacing: 0, - actionsPadding: EdgeInsets.only(right: 26.w), + actionsPadding: EdgeInsets.only(right: 24.w), title: Padding( - padding: EdgeInsets.only(left: 26.w), + padding: EdgeInsets.only(left: 20.w), child: Column( mainAxisAlignment: MainAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.start, children: [ - SizedBox(height: 20.h), + SizedBox(height: 45.h), // 앱의 왼쪽 위 Text( - univName, - style: TextStyle( - color: Colors.white, - // 자간 5% (픽셀 계산) - letterSpacing: 30.sp * 0.05, - // 행간 170% - height: 1.7, - fontWeight: FontWeight.w700, - fontSize: 30.sp, + univProvider.univName, + style: AppTypography.head.b30.copyWith( + color: AppColors.colorWhite, ), ), + SizedBox(height: 9.h), TextButton( style: TextButton.styleFrom( - backgroundColor: Colors.white.withValues(alpha: 0.1), + backgroundColor: AppColors.colorWhite10, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(59.r), ), padding: EdgeInsets.symmetric( - horizontal: 7.w, - vertical: 2.h, + horizontal: 12.w, + vertical: 1.h, ), minimumSize: Size.zero, // 최소 사이즈 제거 tapTargetSize: MaterialTapTargetSize.shrinkWrap, // 탭 영역을 최소화 ), onPressed: () => _selectDate(context), // 탭하면 _selectDate 함수 호출 - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - DateFormat( - 'yyyy년 MM월 dd일 (E)', - 'ko_KR', - ).format(_selectedDate), // 날짜 포맷 - style: TextStyle( - fontSize: 12.sp, - fontWeight: FontWeight.w600, - color: Colors.white, - ), - ), - ], + child: Text( + DateFormat( + 'yyyy년 MM월 dd일 (E)', + 'ko_KR', + ).format(_selectedDate), // 날짜 포맷 + style: AppTypography.caption.sb11.copyWith( + color: AppColors.colorWhite, + ), ), ), SizedBox( diff --git a/lib/widgets/time_grouped_card.dart b/lib/ui/components/cards/time_grouped_card.dart similarity index 71% rename from lib/widgets/time_grouped_card.dart rename to lib/ui/components/cards/time_grouped_card.dart index 46a4514..b44a6ff 100644 --- a/lib/widgets/time_grouped_card.dart +++ b/lib/ui/components/cards/time_grouped_card.dart @@ -1,6 +1,7 @@ import 'package:bobmoo/ui/theme/app_colors.dart'; import 'package:bobmoo/models/meal_by_cafeteria.dart'; -import 'package:bobmoo/widgets/cafeteria_menu_column.dart'; +import 'package:bobmoo/ui/theme/app_typography.dart'; +import 'package:bobmoo/ui/components/meal/cafeteria_menu_column.dart'; import 'package:flutter/material.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:flutter_svg/svg.dart'; @@ -23,29 +24,19 @@ class TimeGroupedCard extends StatelessWidget { shadowColor: Colors.black.withValues(alpha: 0.5), elevation: 4, child: Padding( - padding: EdgeInsets.all(12.w), + padding: EdgeInsets.only( + top: 15.h, + bottom: 5.h, + left: 13.w, + right: 13.w, + ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // 섹션 제목 (예: "점심") - Row( - children: [ - _getIconForMeal(title), // 위에서 만든 함수로 아이콘 가져오기 - SizedBox( - width: 5.w, - ), - Text( - title, - style: TextStyle( - fontSize: 21.sp, - fontWeight: FontWeight.w700, - // 자간 5% - letterSpacing: 21.sp * 0.05, - // 행간 170% - height: 1.7, - ), - ), - ], + Text( + title, + style: AppTypography.head.b21, ), // 각 식당별 메뉴 목록 ListView.separated( @@ -58,12 +49,7 @@ class TimeGroupedCard extends StatelessWidget { itemBuilder: (BuildContext context, int index) { // 각 인덱스에 해당하는 식당 메뉴 위젯을 반환 return Padding( - padding: EdgeInsets.only( - left: 13.w, - right: 5.w, - top: 4.h, - bottom: 12.h, - ), + padding: EdgeInsets.symmetric(vertical: 8.h, horizontal: 4.w), child: CafeteriaMenuColumn( data: mealData[index], mealType: title, @@ -73,10 +59,11 @@ class TimeGroupedCard extends StatelessWidget { }, separatorBuilder: (BuildContext context, int index) { // 각 아이템 사이에 들어갈 구분선 위젯을 반환 + // thickness를 정수로 고정해야 소수 픽셀으로 인한 렌더링 불일치 방지 return Divider( - height: 10.h, - thickness: 1.5, - color: AppColors.grayDividerColor, + height: 1, + thickness: 2, + color: AppColors.colorGray5, ); }, ), @@ -88,6 +75,7 @@ class TimeGroupedCard extends StatelessWidget { } // 아이콘을 결정하는 함수 +// ignore: unused_element Widget _getIconForMeal(String title) { // => 를 사용해 바로 위젯을 반환합니다. final icon = switch (title) { diff --git a/lib/widgets/cafeteria_menu_column.dart b/lib/ui/components/meal/cafeteria_menu_column.dart similarity index 65% rename from lib/widgets/cafeteria_menu_column.dart rename to lib/ui/components/meal/cafeteria_menu_column.dart index d24da0f..a1c9c98 100644 --- a/lib/widgets/cafeteria_menu_column.dart +++ b/lib/ui/components/meal/cafeteria_menu_column.dart @@ -1,7 +1,8 @@ import 'package:bobmoo/models/meal_by_cafeteria.dart'; import 'package:bobmoo/models/menu_model.dart'; -import 'package:bobmoo/widgets/meal_item_row.dart'; -import 'package:bobmoo/widgets/open_status_badge.dart'; +import 'package:bobmoo/ui/components/meal/meal_item_row.dart'; +import 'package:bobmoo/ui/components/meal/open_status_badge.dart'; +import 'package:bobmoo/ui/theme/app_typography.dart'; import 'package:flutter/material.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; @@ -29,24 +30,13 @@ class CafeteriaMenuColumn extends StatelessWidget { children: [ Text( data.cafeteriaName, - style: TextStyle( - fontSize: 18.sp, - fontWeight: FontWeight.w600, - // 자간 5% - letterSpacing: 18.sp * 0.05, - // 행간 170% - height: 1.7, - ), + style: AppTypography.head.sb18, ), - SizedBox(width: 3.w), + SizedBox(width: 5.w), Expanded( child: Text( _hoursTextForMealType(data.hours, mealType), - style: TextStyle( - fontSize: 9.sp, - color: Colors.black54, - fontWeight: FontWeight.w600, - ), + style: AppTypography.caption.sb9, overflow: TextOverflow.ellipsis, ), ), @@ -57,9 +47,22 @@ class CafeteriaMenuColumn extends StatelessWidget { ), ], ), - SizedBox(height: 3.h), // 식당의 메뉴들 - ...data.meals.map((meal) => MealItemRow(meal: meal)), + ListView.builder( + padding: EdgeInsets.zero, + // 내용만큼 크기를 줄이도록 설정 + shrinkWrap: true, + // 스크롤 방지 + physics: const NeverScrollableScrollPhysics(), + itemCount: data.meals.length, + itemBuilder: (BuildContext context, int index) { + // 각 인덱스에 해당하는 식당 메뉴 위젯을 반환 + return Padding( + padding: EdgeInsets.all(3.w), + child: MealItemRow(meal: data.meals[index]), + ); + }, + ), ], ); } diff --git a/lib/widgets/meal_item_row.dart b/lib/ui/components/meal/meal_item_row.dart similarity index 69% rename from lib/widgets/meal_item_row.dart rename to lib/ui/components/meal/meal_item_row.dart index ae1ee9c..4178145 100644 --- a/lib/widgets/meal_item_row.dart +++ b/lib/ui/components/meal/meal_item_row.dart @@ -1,10 +1,12 @@ -import 'package:bobmoo/ui/theme/app_colors.dart'; import 'package:bobmoo/models/menu_model.dart'; +import 'package:bobmoo/ui/theme/app_colors.dart'; +import 'package:bobmoo/ui/theme/app_typography.dart'; import 'package:flutter/material.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; class MealItemRow extends StatelessWidget { final MealItem meal; + const MealItemRow({super.key, required this.meal}); @override @@ -17,7 +19,7 @@ class MealItemRow extends StatelessWidget { // 1. 코스 (A, B, C...) Text( "${meal.course} ", - style: TextStyle(fontSize: 15.sp, fontWeight: FontWeight.w500), + style: AppTypography.caption.m15, ), // 2. 메뉴 이름 Expanded( @@ -32,28 +34,18 @@ class MealItemRow extends StatelessWidget { .map((entry) { return Text( "${entry.value} ", - style: TextStyle( - fontSize: 15.sp, - fontWeight: FontWeight.w400, - ), + style: AppTypography.caption.r15, ); }) .toList(), ), ), Padding( - padding: EdgeInsets.zero, + padding: EdgeInsets.only(top: 3.h), child: Text( "${meal.price}원", - style: TextStyle( - fontSize: 11.sp, - fontFamily: 'NanumSquareRound', - fontWeight: FontWeight.w700, - color: AppColors.greyTextColor, - // 자간 5% - letterSpacing: 11.sp * 0.02, - // 행간 21px - height: 2.0, + style: AppTypography.button.sb11.copyWith( + color: AppColors.colorGray3, ), ), ), diff --git a/lib/widgets/open_status_badge.dart b/lib/ui/components/meal/open_status_badge.dart similarity index 92% rename from lib/widgets/open_status_badge.dart rename to lib/ui/components/meal/open_status_badge.dart index 87e69cb..facc96e 100644 --- a/lib/widgets/open_status_badge.dart +++ b/lib/ui/components/meal/open_status_badge.dart @@ -1,5 +1,6 @@ import 'package:bobmoo/ui/theme/app_colors.dart'; import 'package:bobmoo/models/menu_model.dart'; +import 'package:bobmoo/ui/theme/app_typography.dart'; import 'package:flutter/material.dart'; import 'package:bobmoo/utils/hours_parser.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; @@ -73,21 +74,16 @@ class _StatusBadge extends StatelessWidget { @override Widget build(BuildContext context) { return Container( - padding: EdgeInsets.symmetric(horizontal: 10.w, vertical: 1.5.h), + padding: EdgeInsets.symmetric(vertical: 1.5.h, horizontal: 10.w), + height: 24.h, decoration: BoxDecoration( color: backgroundColor, borderRadius: BorderRadius.circular(24.r), ), - child: Text( - text, - style: TextStyle( - fontSize: 11.sp, - fontWeight: FontWeight.w500, - // 자간 4% - letterSpacing: 11.sp * 0.04, - // 행간 21px - height: 2.0, - color: textColor, + child: Center( + child: Text( + text, + style: AppTypography.button.sb11.copyWith(color: textColor), ), ), );