From 2fab3af26d2431de51f2e0c94be2e820dbd857ba Mon Sep 17 00:00:00 2001 From: TtiPo Date: Mon, 28 Sep 2026 19:35:39 +0900 Subject: [PATCH] =?UTF-8?q?feat(flutter):=20=ED=99=88=20=ED=99=94=EB=A9=B4?= =?UTF-8?q?=20=EA=B5=AC=ED=98=84=EA=B3=BC=20=EB=B0=98=EB=A0=A4=EB=8F=99?= =?UTF-8?q?=EB=AC=BC=20=EB=93=B1=EB=A1=9D=20=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 시안 screen-home 의 흐름을 옮겼다. - 인사말(소셜 프로필 이름, 모르면 "집사") · 알림 아이콘 - 우리 아이: 없으면 등록 안내 카드 → 등록 화면, 있으면 아이 고르기 + 펫 명함 빈 슬롯 - 일정(오늘 일정·오늘 할 일·예약 일정)·다이어리는 해당 기능 전까지 시안의 빈 상태로 표시 - 등록·수정 화면을 탭바 위에 덮는 라우트로 연결, 토스트를 모든 화면 위에 표시 Co-Authored-By: Claude Opus 5.5 (1M context) --- flutter/lib/app.dart | 4 +- flutter/lib/core/router/app_router.dart | 20 ++- flutter/lib/core/router/app_routes.dart | 5 + .../data/repositories/auth_repository.dart | 7 + .../presentation/providers/user_provider.dart | 12 ++ .../home/presentation/pages/home_page.dart | 120 +++++++++++++ .../widgets/home_pets_section.dart | 167 ++++++++++++++++++ .../widgets/home_schedule_section.dart | 142 +++++++++++++++ .../presentation/widgets/name_card_empty.dart | 107 +++++++++++ flutter/test/home_register_flow_test.dart | 92 ++++++++++ 10 files changed, 673 insertions(+), 3 deletions(-) create mode 100644 flutter/lib/features/auth/presentation/providers/user_provider.dart create mode 100644 flutter/lib/features/home/presentation/pages/home_page.dart create mode 100644 flutter/lib/features/home/presentation/widgets/home_pets_section.dart create mode 100644 flutter/lib/features/home/presentation/widgets/home_schedule_section.dart create mode 100644 flutter/lib/features/pet_card/presentation/widgets/name_card_empty.dart create mode 100644 flutter/test/home_register_flow_test.dart diff --git a/flutter/lib/app.dart b/flutter/lib/app.dart index 51bca10..eb75669 100644 --- a/flutter/lib/app.dart +++ b/flutter/lib/app.dart @@ -2,8 +2,9 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:tma_app/core/router/app_router.dart'; import 'package:tma_app/core/theme/app_theme.dart'; +import 'package:tma_app/shared/widgets/rup_toast.dart'; -/// 앱 루트 — 테마와 라우터를 연결한다. +/// 앱 루트 — 테마와 라우터를 연결하고, 모든 화면 위에 토스트 자리를 둔다. class RupApp extends ConsumerWidget { const RupApp({super.key}); @@ -14,6 +15,7 @@ class RupApp extends ConsumerWidget { debugShowCheckedModeBanner: false, theme: AppTheme.light, routerConfig: ref.watch(appRouterProvider), + builder: (context, child) => ToastHost(child: child ?? const SizedBox.shrink()), ); } } diff --git a/flutter/lib/core/router/app_router.dart b/flutter/lib/core/router/app_router.dart index f69fc0f..bdffe41 100644 --- a/flutter/lib/core/router/app_router.dart +++ b/flutter/lib/core/router/app_router.dart @@ -5,8 +5,10 @@ import 'package:tma_app/core/constants/app_sizes.dart'; import 'package:tma_app/core/router/app_routes.dart'; import 'package:tma_app/features/auth/presentation/pages/login_page.dart'; import 'package:tma_app/features/entry/presentation/providers/entry_provider.dart'; +import 'package:tma_app/features/home/presentation/pages/home_page.dart'; import 'package:tma_app/features/intro/presentation/pages/intro_page.dart'; import 'package:tma_app/features/onboarding/presentation/pages/onboarding_page.dart'; +import 'package:tma_app/features/pet/presentation/pages/pet_register_page.dart'; import 'package:tma_app/features/shell/presentation/pages/main_shell.dart'; import 'package:tma_app/features/shell/presentation/pages/tab_placeholder_page.dart'; import 'package:tma_app/features/splash/presentation/pages/splash_page.dart'; @@ -28,7 +30,11 @@ final appRouterProvider = Provider((ref) { final refresh = ValueNotifier(0); ref.listen(entryProvider, (previous, next) => refresh.value++); + // 탭바 위에 덮는 화면(등록 등)은 최상위 내비게이터에 올린다 + final rootKey = GlobalKey(); + final router = GoRouter( + navigatorKey: rootKey, initialLocation: AppRoutes.splash, refreshListenable: refresh, redirect: (context, state) => AppRoutes.redirectFor( @@ -53,6 +59,17 @@ final appRouterProvider = Provider((ref) { pageBuilder: (context, state) => _fadePage(state, const OnboardingPage()), ), + GoRoute( + path: AppRoutes.petRegister, + parentNavigatorKey: rootKey, + builder: (context, state) => const PetRegisterPage(), + ), + GoRoute( + path: AppRoutes.petEditPattern, + parentNavigatorKey: rootKey, + builder: (context, state) => + PetRegisterPage(petId: state.pathParameters['petId']), + ), StatefulShellRoute.indexedStack( pageBuilder: (context, state, navigationShell) => _fadePage(state, MainShell(navigationShell: navigationShell)), @@ -61,8 +78,7 @@ final appRouterProvider = Provider((ref) { routes: [ GoRoute( path: AppRoutes.home, - builder: (context, state) => - const TabPlaceholderPage(title: '홈'), + builder: (context, state) => const HomePage(), ), ], ), diff --git a/flutter/lib/core/router/app_routes.dart b/flutter/lib/core/router/app_routes.dart index 007ee6f..de7ef6f 100644 --- a/flutter/lib/core/router/app_routes.dart +++ b/flutter/lib/core/router/app_routes.dart @@ -13,6 +13,11 @@ abstract final class AppRoutes { static const String petCards = '/pet-cards'; static const String myPage = '/mypage'; + // 탭 위에 덮는 화면 (탭바 없음) + static const String petRegister = '/pets/new'; + static const String petEditPattern = '/pets/:petId/edit'; + static String petEdit(String petId) => '/pets/$petId/edit'; + /// 로그인 전에만 머무를 수 있는 화면 static const Set _signedOutOnly = {intro, login}; diff --git a/flutter/lib/features/auth/data/repositories/auth_repository.dart b/flutter/lib/features/auth/data/repositories/auth_repository.dart index 9467e46..8dd1e2f 100644 --- a/flutter/lib/features/auth/data/repositories/auth_repository.dart +++ b/flutter/lib/features/auth/data/repositories/auth_repository.dart @@ -10,6 +10,9 @@ abstract interface class AuthRepository { /// 저장된 세션이 있는지 (앱 시작 시 진입 화면 결정에 쓴다) bool get hasSession; + /// 인사말에 쓰는 이름 — 소셜 계정 프로필에서 받는다. 모르면 null + String? get nickname; + Future signIn(SocialProvider provider); Future signOut(); @@ -27,6 +30,10 @@ class DemoAuthRepository implements AuthRepository { @override bool get hasSession => _prefs.containsKey(_kDemoSessionKey); + /// 소셜 프로필을 받지 않으므로 이름을 알 수 없다 + @override + String? get nickname => null; + @override Future signIn(SocialProvider provider) async { await _prefs.setString(_kDemoSessionKey, provider.name); diff --git a/flutter/lib/features/auth/presentation/providers/user_provider.dart b/flutter/lib/features/auth/presentation/providers/user_provider.dart new file mode 100644 index 0000000..082bf01 --- /dev/null +++ b/flutter/lib/features/auth/presentation/providers/user_provider.dart @@ -0,0 +1,12 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:tma_app/features/auth/data/repositories/auth_repository.dart'; +import 'package:tma_app/features/entry/presentation/providers/entry_provider.dart'; + +/// 이름을 모를 때 부르는 호칭 — 로그인 화면의 "반가워요, 집사님!" 과 맞춘다 +const String kDefaultNickname = '집사'; + +/// 인사말에 쓰는 사용자 이름 (로그인·로그아웃하면 다시 읽는다) +final userNicknameProvider = Provider((ref) { + ref.watch(entryProvider.select((s) => s.signedIn)); + return ref.watch(authRepositoryProvider).nickname ?? kDefaultNickname; +}); diff --git a/flutter/lib/features/home/presentation/pages/home_page.dart b/flutter/lib/features/home/presentation/pages/home_page.dart new file mode 100644 index 0000000..2938bb1 --- /dev/null +++ b/flutter/lib/features/home/presentation/pages/home_page.dart @@ -0,0 +1,120 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import 'package:tma_app/core/constants/app_colors.dart'; +import 'package:tma_app/core/constants/app_sizes.dart'; +import 'package:tma_app/core/router/app_routes.dart'; +import 'package:tma_app/features/auth/presentation/providers/user_provider.dart'; +import 'package:tma_app/features/home/presentation/widgets/home_pets_section.dart'; +import 'package:tma_app/features/home/presentation/widgets/home_schedule_section.dart'; +import 'package:tma_app/shared/widgets/icons/rup_icon.dart'; +import 'package:tma_app/shared/widgets/rup_card.dart'; +import 'package:tma_app/shared/widgets/section_title.dart'; + +/// 탭바 뒤로 콘텐츠가 가려지지 않게 두는 여유 (시안 .rup-screen 아래 124 에서 탭바 몫을 뺀 값) +const double _kBottomGap = 30; + +/// 홈 — 인사말, 우리 아이, 오늘 일정·예약 일정, 다이어리 +class HomePage extends ConsumerWidget { + const HomePage({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final nickname = ref.watch(userNicknameProvider); + final today = DateTime.now(); + + Widget padded(Widget child) => Padding( + padding: const EdgeInsets.symmetric(horizontal: AppSpacing.xl), + child: child, + ); + + return SafeArea( + bottom: false, + child: ListView( + padding: EdgeInsets.only( + top: AppSpacing.xs, + // 탭바 높이는 Scaffold(extendBody) 가 padding.bottom 에 더해 준다 + bottom: MediaQuery.paddingOf(context).bottom + _kBottomGap, + ), + children: [ + padded(_HomeHeader(nickname: nickname)), + // 시안: 헤더 아래 18 + 소제목 위 6 (소제목 줄의 링크 터치 여백만큼 뺀다) + const SizedBox(height: 16), + padded( + Padding( + padding: const EdgeInsets.symmetric(horizontal: AppSpacing.xs), + child: SectionTitle( + title: '우리 아이', + sub: true, + trailing: '아이 상세보기', + onTrailingTap: () => context.go(AppRoutes.myPage), + ), + ), + ), + padded( + HomePetsSection( + onRegister: () => context.push(AppRoutes.petRegister), + // 펫 명함 만들기는 명함 탭에서 이어서 만든다 + onCreateCard: (pet) => context.go(AppRoutes.petCards), + ), + ), + const DividerBand(), + padded( + HomeScheduleSection( + today: today, + onSeeAll: () => context.go(AppRoutes.records), + ), + ), + const DividerBand(), + padded( + Padding( + padding: const EdgeInsets.symmetric(horizontal: AppSpacing.xs), + child: SectionTitle( + title: '다이어리', + trailing: '더보기', + onTrailingTap: () => context.go(AppRoutes.records), + ), + ), + ), + const SizedBox(height: 19), + padded(const RupCard(child: EmptyMessage('아직 작성한 다이어리가 없어요'))), + ], + ), + ); + } +} + +class _HomeHeader extends StatelessWidget { + const _HomeHeader({required this.nickname}); + + final String nickname; + + @override + Widget build(BuildContext context) { + return Row( + children: [ + Expanded( + child: Text( + '안녕하세요, $nickname님', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.headlineSmall, + ), + ), + // 알림 화면은 흐름 순서상 뒤에 만든다 — 지금은 아이콘만 둔다 + const SizedBox.square( + dimension: 42, + child: Center( + child: RupIcon( + RupIconName.bell, + size: 23, + stroke: 2.2, + color: AppColors.ink2, + semanticLabel: '알림', + ), + ), + ), + ], + ); + } +} diff --git a/flutter/lib/features/home/presentation/widgets/home_pets_section.dart b/flutter/lib/features/home/presentation/widgets/home_pets_section.dart new file mode 100644 index 0000000..040e25a --- /dev/null +++ b/flutter/lib/features/home/presentation/widgets/home_pets_section.dart @@ -0,0 +1,167 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:tma_app/core/constants/app_colors.dart'; +import 'package:tma_app/core/constants/app_sizes.dart'; +import 'package:tma_app/features/pet/domain/pet.dart'; +import 'package:tma_app/features/pet/presentation/providers/pet_providers.dart'; +import 'package:tma_app/features/pet/presentation/widgets/pet_avatar.dart'; +import 'package:tma_app/features/pet_card/presentation/widgets/name_card_empty.dart'; +import 'package:tma_app/shared/widgets/icons/rup_icon.dart'; +import 'package:tma_app/shared/widgets/rup_card.dart'; + +/// 우리 아이 고르기 — 원형 사진 치수 (시안 .rup-petpick__ring 58 / 아바타 46) +const double _kRingSize = 58; +const double _kAvatarSize = 46; +const double _kRingBorder = 2.5; + +/// 홈 "우리 아이" — 아이가 없으면 등록 안내, 있으면 아이 고르기 + 대표 명함 자리 +class HomePetsSection extends ConsumerWidget { + const HomePetsSection({super.key, required this.onRegister, required this.onCreateCard}); + + final VoidCallback onRegister; + final ValueChanged onCreateCard; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final pets = ref.watch(petListProvider).value; + // 불러오는 동안은 자리만 잡는다 — "없어요" 가 스치지 않게 + if (pets == null) return const SizedBox(height: _kRingSize + 40); + if (pets.isEmpty) { + // 시안: 카드 위 26, 아래는 비어 있는 아이 목록 줄의 여백(4+8)이 남는다 + return Padding( + padding: const EdgeInsets.only(top: 22, bottom: AppSpacing.md), + child: _RegisterPetCard(onTap: onRegister), + ); + } + + final selected = ref.watch(selectedPetProvider); + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // 시안: 소제목 아래 12 (소제목 줄의 링크 터치 여백만큼 뺀다) + const SizedBox(height: AppSpacing.sm), + _PetPicker( + pets: pets, + selectedId: selected?.id, + onSelect: (id) => ref.read(selectedPetIdProvider.notifier).select(id), + ), + if (selected != null) ...[ + const SizedBox(height: 20), + // 펫 명함 기능 전까지는 모든 아이가 "명함 없음" 상태다 + NameCardEmpty(petName: selected.name, onCreate: () => onCreateCard(selected)), + ], + ], + ); + } +} + +/// 아이가 한 마리도 없을 때 — 등록 안내 카드 +class _RegisterPetCard extends StatelessWidget { + const _RegisterPetCard({required this.onTap}); + + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final textTheme = Theme.of(context).textTheme; + return Semantics( + button: true, + child: RupCard( + onTap: onTap, + padding: const EdgeInsets.symmetric(horizontal: AppSpacing.lg, vertical: 15), + child: Row( + children: [ + Container( + width: 44, + height: 44, + decoration: const BoxDecoration(color: AppColors.primary50, shape: BoxShape.circle), + alignment: Alignment.center, + child: const RupIcon(RupIconName.plus, size: 22, stroke: 2.4, color: AppColors.primary600), + ), + const SizedBox(width: AppSpacing.md), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '우리 아이를 등록해 주세요', + style: textTheme.titleSmall!.copyWith(fontSize: 14), + ), + const SizedBox(height: 2), + Text( + '등록하면 일정과 펫 명함을 쓸 수 있어요', + style: textTheme.bodySmall!.copyWith(fontSize: 12.5, color: AppColors.muted), + ), + ], + ), + ), + const RupIcon(RupIconName.chevR, size: 16, stroke: 2.2, color: AppColors.muted), + ], + ), + ), + ); + } +} + +/// 가로로 넘기는 아이 목록 — 고른 아이는 주황 테두리 +class _PetPicker extends StatelessWidget { + const _PetPicker({required this.pets, required this.selectedId, required this.onSelect}); + + final List pets; + final String? selectedId; + final ValueChanged onSelect; + + @override + Widget build(BuildContext context) { + final textTheme = Theme.of(context).textTheme; + return SingleChildScrollView( + scrollDirection: Axis.horizontal, + clipBehavior: Clip.none, + padding: const EdgeInsets.fromLTRB(2, AppSpacing.xs, 2, AppSpacing.sm), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (final (i, pet) in pets.indexed) ...[ + if (i > 0) const SizedBox(width: 14), + Semantics( + button: true, + selected: pet.id == selectedId, + label: pet.name, + excludeSemantics: true, + child: GestureDetector( + onTap: () => onSelect(pet.id), + child: Column( + children: [ + AnimatedContainer( + duration: AppDurations.fast, + width: _kRingSize, + height: _kRingSize, + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all( + color: pet.id == selectedId ? AppColors.primary : Colors.transparent, + width: _kRingBorder, + ), + ), + alignment: Alignment.center, + child: PetAvatar(pet: pet, size: _kAvatarSize), + ), + const SizedBox(height: 7), + Text( + pet.name, + style: textTheme.bodySmall!.copyWith( + fontSize: 12.5, + fontWeight: pet.id == selectedId ? FontWeight.w700 : FontWeight.w600, + color: pet.id == selectedId ? AppColors.primary600 : AppColors.muted, + ), + ), + ], + ), + ), + ), + ], + ], + ), + ); + } +} diff --git a/flutter/lib/features/home/presentation/widgets/home_schedule_section.dart b/flutter/lib/features/home/presentation/widgets/home_schedule_section.dart new file mode 100644 index 0000000..f28c529 --- /dev/null +++ b/flutter/lib/features/home/presentation/widgets/home_schedule_section.dart @@ -0,0 +1,142 @@ +import 'package:flutter/material.dart'; +import 'package:tma_app/core/constants/app_colors.dart'; +import 'package:tma_app/core/constants/app_sizes.dart'; +import 'package:tma_app/core/utils/date_format.dart'; +import 'package:tma_app/shared/widgets/icons/rup_icon.dart'; +import 'package:tma_app/shared/widgets/rup_card.dart'; +import 'package:tma_app/shared/widgets/section_title.dart'; + +/// 홈 "일정" — 오늘 일정(다음 일정·오늘 할 일) + 예약 일정. +/// 일정 기능을 만들기 전이라 시안의 "일정 없음" 상태를 보여 준다. +class HomeScheduleSection extends StatelessWidget { + const HomeScheduleSection({super.key, required this.today, required this.onSeeAll}); + + final DateTime today; + final VoidCallback onSeeAll; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: AppSpacing.xs), + child: SectionTitle(title: '일정', trailing: '전체보기', onTrailingTap: onSeeAll), + ), + const SizedBox(height: 17), + Padding( + padding: const EdgeInsets.symmetric(horizontal: AppSpacing.xs), + child: SectionTitle( + title: '오늘 일정', + sub: true, + trailing: '${formatMonthDay(today)} ${weekdayKo(today)}요일', + ), + ), + const SizedBox(height: 10), + const _NextTaskOff(text: '오늘 등록된 일정이 없어요'), + const SizedBox(height: AppSpacing.md), + const RupCard( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _TodayProgress(done: 0, total: 0), + EmptyMessage('오늘은 등록된 일정이 없어요'), + ], + ), + ), + const SizedBox(height: 18), + const Padding( + padding: EdgeInsets.symmetric(horizontal: AppSpacing.xs), + child: SectionTitle(title: '예약 일정', sub: true, trailing: '병원 · 미용 · 검진'), + ), + const SizedBox(height: 10), + const RupCard(child: EmptyMessage('예약된 일정이 없어요')), + ], + ); + } +} + +/// 다음 일정 카드의 비활성 상태 (.rup-nextcard--off) +class _NextTaskOff extends StatelessWidget { + const _NextTaskOff({required this.text}); + + final String text; + + @override + Widget build(BuildContext context) { + return Opacity( + opacity: .8, + child: RupCard( + color: AppColors.surface2, + shadow: false, + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 13), + child: Row( + children: [ + const RupIcon(RupIconName.check, size: 16, stroke: 2.6, color: AppColors.muted), + const SizedBox(width: 9), + Expanded( + child: Text( + text, + style: Theme.of(context).textTheme.bodyMedium!.copyWith( + fontWeight: FontWeight.w700, + color: AppColors.muted, + height: 1.3, + ), + ), + ), + ], + ), + ), + ); + } +} + +/// 오늘 할 일 진행도 — "오늘 할 일 [■■□] n/m 완료" +class _TodayProgress extends StatelessWidget { + const _TodayProgress({required this.done, required this.total}); + + final int done; + final int total; + + @override + Widget build(BuildContext context) { + final allDone = total > 0 && done == total; + final label = Theme.of(context).textTheme.bodySmall!.copyWith( + fontSize: 12.5, + fontWeight: FontWeight.w700, + color: AppColors.muted, + height: 1.2, + ); + return Container( + padding: const EdgeInsets.symmetric(horizontal: AppSpacing.lg, vertical: 13), + decoration: const BoxDecoration( + border: Border(bottom: BorderSide(color: AppColors.line2)), + ), + child: Row( + children: [ + Text('오늘 할 일', style: label), + const SizedBox(width: 11), + Expanded( + child: ClipRRect( + borderRadius: BorderRadius.circular(6), + child: LinearProgressIndicator( + value: total == 0 ? 0 : done / total, + minHeight: 6, + backgroundColor: AppColors.line2, + color: AppColors.primary, + ), + ), + ), + const SizedBox(width: 11), + Text( + '$done/$total 완료', + style: label.copyWith( + fontWeight: FontWeight.w800, + color: allDone ? AppColors.accent : AppColors.muted, + ), + ), + ], + ), + ); + } +} diff --git a/flutter/lib/features/pet_card/presentation/widgets/name_card_empty.dart b/flutter/lib/features/pet_card/presentation/widgets/name_card_empty.dart new file mode 100644 index 0000000..1505f8b --- /dev/null +++ b/flutter/lib/features/pet_card/presentation/widgets/name_card_empty.dart @@ -0,0 +1,107 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_svg/flutter_svg.dart'; +import 'package:tma_app/core/constants/app_assets.dart'; +import 'package:tma_app/core/constants/app_colors.dart'; +import 'package:tma_app/core/constants/app_sizes.dart'; +import 'package:tma_app/core/theme/app_typography.dart'; +import 'package:tma_app/shared/widgets/dashed_border_box.dart'; +import 'package:tma_app/shared/widgets/icons/rup_icon.dart'; +import 'package:tma_app/shared/widgets/pressable.dart'; + +/// 빈 명함 슬롯 치수 — 시안 .rup-namecard-empty +const double _kMinHeight = 193; +const double _kRadius = 18; + +/// 아직 펫 명함이 없는 아이 — 이름 + 안내 + "명함 만들기" +class NameCardEmpty extends StatelessWidget { + const NameCardEmpty({super.key, required this.petName, required this.onCreate}); + + final String petName; + final VoidCallback onCreate; + + @override + Widget build(BuildContext context) { + final textTheme = Theme.of(context).textTheme; + return Semantics( + button: true, + label: '$petName의 펫 명함 만들기', + child: MouseRegion( + cursor: SystemMouseCursors.click, + child: GestureDetector( + onTap: onCreate, + child: DashedBorderBox( + color: AppColors.emptyCardBorder, + radius: _kRadius, + backgroundColor: AppColors.emptyCardBg, + child: ConstrainedBox( + constraints: const BoxConstraints(minHeight: _kMinHeight, minWidth: double.infinity), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 22, vertical: 18), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + SvgPicture.asset( + AppAssets.addCard, + width: 40, + height: 40, + colorFilter: const ColorFilter.mode(AppColors.emptyCardIcon, BlendMode.srcIn), + ), + const SizedBox(height: 9), + Text.rich( + TextSpan(children: [ + TextSpan( + text: petName, + style: const TextStyle(color: AppColors.primary600), + ), + const TextSpan(text: '의 펫 명함이 아직 없어요'), + ]), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: textTheme.titleSmall!.copyWith(fontWeight: FontWeight.w600), + ), + const SizedBox(height: 5), + Text( + '등록한 정보로 나만의 명함을 만들어 보세요', + textAlign: TextAlign.center, + style: textTheme.bodySmall!.copyWith( + fontSize: 12.5, + fontWeight: FontWeight.w500, + color: AppColors.muted, + ), + ), + const SizedBox(height: 15), + Pressable( + onTap: onCreate, + pressedScale: .96, + child: Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: AppSpacing.xxl, vertical: 12), + decoration: BoxDecoration( + color: AppColors.surface2, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: AppColors.emptyCardButtonBorder, width: 1.5), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const RupIcon(RupIconName.plus, size: 17, stroke: 2.2, color: AppColors.primary600), + const SizedBox(width: 7), + Text( + '명함 만들기', + style: AppTypography.button(13.5, color: AppColors.primary600) + .copyWith(height: 1), + ), + ], + ), + ), + ), + ], + ), + ), + ), + ), + ), + ), + ); + } +} diff --git a/flutter/test/home_register_flow_test.dart b/flutter/test/home_register_flow_test.dart new file mode 100644 index 0000000..490ac96 --- /dev/null +++ b/flutter/test/home_register_flow_test.dart @@ -0,0 +1,92 @@ +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:tma_app/app.dart'; +import 'package:tma_app/core/storage/preferences_provider.dart'; +import 'package:tma_app/features/pet/domain/pet.dart'; +import 'package:tma_app/features/pet/presentation/pages/pet_register_page.dart'; + +/// 로그인·온보딩까지 마친 사용자 +const Map _signedIn = { + 'entry.introSeen': true, + 'entry.onboardingSeen': true, + 'auth.demoSession': 'kakao', +}; + +Future _launchHome(WidgetTester tester, {Map extra = const {}}) async { + tester.view.physicalSize = const Size(402, 874); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + + SharedPreferences.setMockInitialValues({..._signedIn, ...extra}); + final prefs = await SharedPreferences.getInstance(); + await tester.pumpWidget( + ProviderScope( + overrides: [sharedPreferencesProvider.overrideWithValue(prefs)], + child: const RupApp(), + ), + ); + // 스플래시 이미지 디코딩 → 2.6초 뒤 홈으로 + await tester.runAsync(() => Future.delayed(const Duration(milliseconds: 300))); + await tester.pump(const Duration(seconds: 4)); + await _settle(tester); +} + +Future _settle(WidgetTester tester) async { + await tester.pump(); + await tester.pump(const Duration(milliseconds: 600)); + await tester.pump(); +} + +void main() { + testWidgets('아이가 없으면 홈에 등록 안내가 보이고, 누르면 등록 화면으로 간다', (tester) async { + await _launchHome(tester); + expect(find.text('안녕하세요, 집사님'), findsOneWidget); + expect(find.text('우리 아이를 등록해 주세요'), findsOneWidget); + expect(find.text('오늘은 등록된 일정이 없어요'), findsOneWidget); + + await tester.tap(find.text('우리 아이를 등록해 주세요')); + await _settle(tester); + expect(find.byType(PetRegisterPage), findsOneWidget); + }); + + testWidgets('기본 정보 없이 등록을 누르면 빠진 칸마다 오류를 보여 준다', (tester) async { + await _launchHome(tester); + await tester.tap(find.text('우리 아이를 등록해 주세요')); + await _settle(tester); + + await tester.ensureVisible(find.text('반려동물 등록').last); + await tester.tap(find.text('반려동물 등록').last); + await _settle(tester); + + expect(find.text('이름을 입력해주세요.'), findsOneWidget); + expect(find.text('종을 선택해주세요.'), findsOneWidget); + expect(find.text('생년월일을 선택해주세요.'), findsOneWidget); + expect(find.text('몸무게를 입력해주세요.'), findsOneWidget); + }); + + testWidgets('등록된 아이가 있으면 아이 목록과 명함 만들기 안내가 보인다', (tester) async { + final pet = Pet.fromInput( + 'pet-1', + PetInput( + name: '콩이', + species: '강아지', + breed: '말티즈', + gender: PetGender.male, + neutered: true, + birthDate: DateTime(2021, 9, 14), + weight: 3.2, + weightUnit: WeightUnit.kg, + ), + ); + await _launchHome(tester, extra: {'pets.v1': jsonEncode([pet.toJson()])}); + + expect(find.text('우리 아이를 등록해 주세요'), findsNothing); + expect(find.text('콩이'), findsWidgets); + expect(find.text('명함 만들기'), findsOneWidget); + }); +} +