feat(flutter): 홈 화면 구현과 반려동물 등록 연결

시안 screen-home 의 흐름을 옮겼다.
- 인사말(소셜 프로필 이름, 모르면 "집사") · 알림 아이콘
- 우리 아이: 없으면 등록 안내 카드 → 등록 화면, 있으면 아이 고르기 + 펫 명함 빈 슬롯
- 일정(오늘 일정·오늘 할 일·예약 일정)·다이어리는 해당 기능 전까지 시안의 빈 상태로 표시
- 등록·수정 화면을 탭바 위에 덮는 라우트로 연결, 토스트를 모든 화면 위에 표시

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-28 19:35:39 +09:00
parent 50d1aeea4b
commit 2fab3af26d
10 changed files with 673 additions and 3 deletions
+3 -1
View File
@@ -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()),
);
}
}
+18 -2
View File
@@ -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<GoRouter>((ref) {
final refresh = ValueNotifier<int>(0);
ref.listen(entryProvider, (previous, next) => refresh.value++);
// 탭바 위에 덮는 화면(등록 등)은 최상위 내비게이터에 올린다
final rootKey = GlobalKey<NavigatorState>();
final router = GoRouter(
navigatorKey: rootKey,
initialLocation: AppRoutes.splash,
refreshListenable: refresh,
redirect: (context, state) => AppRoutes.redirectFor(
@@ -53,6 +59,17 @@ final appRouterProvider = Provider<GoRouter>((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<GoRouter>((ref) {
routes: [
GoRoute(
path: AppRoutes.home,
builder: (context, state) =>
const TabPlaceholderPage(title: '홈'),
builder: (context, state) => const HomePage(),
),
],
),
+5
View File
@@ -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<String> _signedOutOnly = {intro, login};
@@ -10,6 +10,9 @@ abstract interface class AuthRepository {
/// 저장된 세션이 있는지 (앱 시작 시 진입 화면 결정에 쓴다)
bool get hasSession;
/// 인사말에 쓰는 이름 — 소셜 계정 프로필에서 받는다. 모르면 null
String? get nickname;
Future<void> signIn(SocialProvider provider);
Future<void> signOut();
@@ -27,6 +30,10 @@ class DemoAuthRepository implements AuthRepository {
@override
bool get hasSession => _prefs.containsKey(_kDemoSessionKey);
/// 소셜 프로필을 받지 않으므로 이름을 알 수 없다
@override
String? get nickname => null;
@override
Future<void> signIn(SocialProvider provider) async {
await _prefs.setString(_kDemoSessionKey, provider.name);
@@ -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<String>((ref) {
ref.watch(entryProvider.select((s) => s.signedIn));
return ref.watch(authRepositoryProvider).nickname ?? kDefaultNickname;
});
@@ -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: '알림',
),
),
),
],
);
}
}
@@ -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<Pet> 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<Pet> pets;
final String? selectedId;
final ValueChanged<String> 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,
),
),
],
),
),
),
],
],
),
);
}
}
@@ -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,
),
),
],
),
);
}
}
@@ -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),
),
],
),
),
),
],
),
),
),
),
),
),
);
}
}