diff --git a/flutter/assets/images/brand/rup_logo.png b/flutter/assets/images/brand/rup_logo.png
new file mode 100644
index 0000000..41ce6bb
Binary files /dev/null and b/flutter/assets/images/brand/rup_logo.png differ
diff --git a/flutter/assets/images/brand/rup_logo_white.png b/flutter/assets/images/brand/rup_logo_white.png
new file mode 100644
index 0000000..21c57e8
Binary files /dev/null and b/flutter/assets/images/brand/rup_logo_white.png differ
diff --git a/flutter/assets/svg/card_bg/bg_1.svg b/flutter/assets/svg/card_bg/bg_1.svg
new file mode 100644
index 0000000..c80fabd
--- /dev/null
+++ b/flutter/assets/svg/card_bg/bg_1.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/flutter/assets/svg/card_bg/bg_2.svg b/flutter/assets/svg/card_bg/bg_2.svg
new file mode 100644
index 0000000..ce35491
--- /dev/null
+++ b/flutter/assets/svg/card_bg/bg_2.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/flutter/assets/svg/card_bg/bg_3.svg b/flutter/assets/svg/card_bg/bg_3.svg
new file mode 100644
index 0000000..54b7492
--- /dev/null
+++ b/flutter/assets/svg/card_bg/bg_3.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/flutter/assets/svg/card_bg/bg_4.svg b/flutter/assets/svg/card_bg/bg_4.svg
new file mode 100644
index 0000000..a10b3fb
--- /dev/null
+++ b/flutter/assets/svg/card_bg/bg_4.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/flutter/lib/core/constants/app_assets.dart b/flutter/lib/core/constants/app_assets.dart
index 2e2e1e3..c73a332 100644
--- a/flutter/lib/core/constants/app_assets.dart
+++ b/flutter/lib/core/constants/app_assets.dart
@@ -40,6 +40,18 @@ abstract final class AppAssets {
static const String addPhoto = 'assets/svg/common/add_photo.svg';
static const String addCard = 'assets/svg/common/add_card.svg';
+ // 브랜드 로고 (명함 헤더 등)
+ static const String logo = 'assets/images/brand/rup_logo.png';
+ static const String logoWhite = 'assets/images/brand/rup_logo_white.png';
+
+ /// 펫 명함 기본 배경 이미지 4종
+ static const List cardBackgrounds = [
+ 'assets/svg/card_bg/bg_1.svg',
+ 'assets/svg/card_bg/bg_2.svg',
+ 'assets/svg/card_bg/bg_3.svg',
+ 'assets/svg/card_bg/bg_4.svg',
+ ];
+
/// 종 실루엣 아이콘 — [speciesIconKey] 는 dog·cat·… (없으면 other)
static String speciesIcon(String speciesIconKey) =>
'assets/svg/species/$speciesIconKey.svg';
diff --git a/flutter/lib/core/router/app_router.dart b/flutter/lib/core/router/app_router.dart
index bdffe41..35a513b 100644
--- a/flutter/lib/core/router/app_router.dart
+++ b/flutter/lib/core/router/app_router.dart
@@ -9,6 +9,10 @@ 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/pet_card/presentation/pages/pet_card_detail_page.dart';
+import 'package:tma_app/features/pet_card/presentation/pages/pet_card_editor_page.dart';
+import 'package:tma_app/features/pet_card/presentation/pages/pet_card_manage_page.dart';
+import 'package:tma_app/features/pet_card/presentation/pages/pet_cards_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';
@@ -70,6 +74,30 @@ final appRouterProvider = Provider((ref) {
builder: (context, state) =>
PetRegisterPage(petId: state.pathParameters['petId']),
),
+ // 펫 명함 — 경로가 겹치므로 고정 경로(manage·new)를 :cardId 보다 먼저 둔다
+ GoRoute(
+ path: AppRoutes.petCardManage,
+ parentNavigatorKey: rootKey,
+ builder: (context, state) => const PetCardManagePage(),
+ ),
+ GoRoute(
+ path: AppRoutes.petCardCreatePattern,
+ parentNavigatorKey: rootKey,
+ builder: (context, state) =>
+ PetCardEditorPage.create(petId: state.pathParameters['petId']!),
+ ),
+ GoRoute(
+ path: AppRoutes.petCardEditPattern,
+ parentNavigatorKey: rootKey,
+ builder: (context, state) =>
+ PetCardEditorPage.edit(cardId: state.pathParameters['cardId']!),
+ ),
+ GoRoute(
+ path: AppRoutes.petCardDetailPattern,
+ parentNavigatorKey: rootKey,
+ builder: (context, state) =>
+ PetCardDetailPage(cardId: state.pathParameters['cardId']!),
+ ),
StatefulShellRoute.indexedStack(
pageBuilder: (context, state, navigationShell) =>
_fadePage(state, MainShell(navigationShell: navigationShell)),
@@ -95,8 +123,7 @@ final appRouterProvider = Provider((ref) {
routes: [
GoRoute(
path: AppRoutes.petCards,
- builder: (context, state) =>
- const TabPlaceholderPage(title: '펫 명함'),
+ builder: (context, state) => const PetCardsPage(),
),
],
),
diff --git a/flutter/lib/core/router/app_routes.dart b/flutter/lib/core/router/app_routes.dart
index de7ef6f..d428df1 100644
--- a/flutter/lib/core/router/app_routes.dart
+++ b/flutter/lib/core/router/app_routes.dart
@@ -18,6 +18,14 @@ abstract final class AppRoutes {
static const String petEditPattern = '/pets/:petId/edit';
static String petEdit(String petId) => '/pets/$petId/edit';
+ static const String petCardManage = '/pet-cards/manage';
+ static const String petCardCreatePattern = '/pet-cards/new/:petId';
+ static const String petCardDetailPattern = '/pet-cards/:cardId';
+ static const String petCardEditPattern = '/pet-cards/:cardId/edit';
+ static String petCardCreate(String petId) => '/pet-cards/new/$petId';
+ static String petCardDetail(String cardId) => '/pet-cards/$cardId';
+ static String petCardEdit(String cardId) => '/pet-cards/$cardId/edit';
+
/// 로그인 전에만 머무를 수 있는 화면
static const Set _signedOutOnly = {intro, login};
diff --git a/flutter/lib/core/theme/app_typography.dart b/flutter/lib/core/theme/app_typography.dart
index e20726a..255314e 100644
--- a/flutter/lib/core/theme/app_typography.dart
+++ b/flutter/lib/core/theme/app_typography.dart
@@ -22,6 +22,13 @@ double _tracking(double fontSize) => fontSize * -0.01;
/// 앱 TextTheme — 크기는 시안 px(=논리 픽셀) 값을 그대로 쓴다.
/// 화면에서는 `Theme.of(context).textTheme.xxx` 로 꺼내 쓰고, 굵기·색만 copyWith 로 조정한다.
abstract final class AppTypography {
+ /// CSS 'line-height: normal' 에 해당하는 줄 높이 — 시안에서 줄 높이를 따로 주지 않은 글자.
+ /// 지정하지 않으면 본문 기본값(bodyMedium 1.6)을 물려받아 시안보다 줄이 커진다.
+ static const double normalHeight = 1.25;
+
+ /// 제목 서체(ChungbukBold)의 normal 줄 높이 — 글자 크기와 거의 같다
+ static const double displayNormalHeight = 1.1;
+
static TextStyle _ui(double size, FontWeight weight, {double? height}) =>
TextStyle(
fontFamily: AppFonts.ui,
diff --git a/flutter/lib/core/utils/color_utils.dart b/flutter/lib/core/utils/color_utils.dart
new file mode 100644
index 0000000..ab9fe4f
--- /dev/null
+++ b/flutter/lib/core/utils/color_utils.dart
@@ -0,0 +1,25 @@
+import 'dart:ui' show Color;
+
+/// "#RRGGBB" → Color. 형식이 틀리면 null
+Color? colorFromHex(String? hex) {
+ if (hex == null) return null;
+ final m = RegExp(r'^#?([0-9a-fA-F]{6})$').firstMatch(hex.trim());
+ if (m == null) return null;
+ return Color(0xFF000000 | int.parse(m.group(1)!, radix: 16));
+}
+
+/// Color → "#RRGGBB" (대문자)
+String hexOf(Color c) {
+ final rgb = c.toARGB32() & 0xFFFFFF;
+ return '#${rgb.toRadixString(16).padLeft(6, '0').toUpperCase()}';
+}
+
+/// [amount](0~1) 만큼 흰색과 섞어 밝게 — 시안 lightenHex (직접 고른 헤더색의 두 번째 색)
+Color lighten(Color c, double amount) {
+ int mix(double channel) {
+ final v = (channel * 255).round();
+ return (v + (255 - v) * amount).round();
+ }
+
+ return Color.fromARGB(255, mix(c.r), mix(c.g), mix(c.b));
+}
diff --git a/flutter/lib/features/home/presentation/pages/home_page.dart b/flutter/lib/features/home/presentation/pages/home_page.dart
index 2938bb1..676c365 100644
--- a/flutter/lib/features/home/presentation/pages/home_page.dart
+++ b/flutter/lib/features/home/presentation/pages/home_page.dart
@@ -51,13 +51,7 @@ class HomePage extends ConsumerWidget {
),
),
),
- padded(
- HomePetsSection(
- onRegister: () => context.push(AppRoutes.petRegister),
- // 펫 명함 만들기는 명함 탭에서 이어서 만든다
- onCreateCard: (pet) => context.go(AppRoutes.petCards),
- ),
- ),
+ padded(HomePetsSection(onRegister: () => context.push(AppRoutes.petRegister))),
const DividerBand(),
padded(
HomeScheduleSection(
diff --git a/flutter/lib/features/home/presentation/widgets/home_pets_section.dart b/flutter/lib/features/home/presentation/widgets/home_pets_section.dart
index 040e25a..df49f07 100644
--- a/flutter/lib/features/home/presentation/widgets/home_pets_section.dart
+++ b/flutter/lib/features/home/presentation/widgets/home_pets_section.dart
@@ -1,54 +1,81 @@
+import 'dart:async';
+
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/features/pet/domain/pet.dart';
+import 'package:tma_app/core/router/app_routes.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/presentation/widgets/pet_picker.dart';
+import 'package:tma_app/features/pet_card/presentation/pages/pet_cards_page.dart';
+import 'package:tma_app/features/pet_card/presentation/providers/pet_card_providers.dart';
+import 'package:tma_app/features/pet_card/presentation/widgets/name_card.dart';
import 'package:tma_app/features/pet_card/presentation/widgets/name_card_empty.dart';
+import 'package:tma_app/shared/widgets/capture_boundary.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;
+/// 불러오는 동안 자리 높이 (아이 목록 줄 정도)
+const double _kLoadingHeight = 100;
-/// 홈 "우리 아이" — 아이가 없으면 등록 안내, 있으면 아이 고르기 + 대표 명함 자리
-class HomePetsSection extends ConsumerWidget {
- const HomePetsSection({super.key, required this.onRegister, required this.onCreateCard});
+/// 홈 "우리 아이" — 아이가 없으면 등록 안내, 있으면 아이 고르기 + 대표 명함(없으면 만들기 안내)
+class HomePetsSection extends ConsumerStatefulWidget {
+ const HomePetsSection({super.key, required this.onRegister});
final VoidCallback onRegister;
- final ValueChanged onCreateCard;
@override
- Widget build(BuildContext context, WidgetRef ref) {
+ ConsumerState createState() => _HomePetsSectionState();
+}
+
+class _HomePetsSectionState extends ConsumerState {
+ final _capture = CaptureController();
+
+ @override
+ Widget build(BuildContext context) {
final pets = ref.watch(petListProvider).value;
// 불러오는 동안은 자리만 잡는다 — "없어요" 가 스치지 않게
- if (pets == null) return const SizedBox(height: _kRingSize + 40);
+ if (pets == null) return const SizedBox(height: _kLoadingHeight);
if (pets.isEmpty) {
// 시안: 카드 위 26, 아래는 비어 있는 아이 목록 줄의 여백(4+8)이 남는다
return Padding(
padding: const EdgeInsets.only(top: 22, bottom: AppSpacing.md),
- child: _RegisterPetCard(onTap: onRegister),
+ child: _RegisterPetCard(onTap: widget.onRegister),
);
}
final selected = ref.watch(selectedPetProvider);
+ final card = selected == null ? null : ref.watch(representativeCardProvider(selected.id));
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// 시안: 소제목 아래 12 (소제목 줄의 링크 터치 여백만큼 뺀다)
const SizedBox(height: AppSpacing.sm),
- _PetPicker(
+ 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)),
+ if (card == null)
+ NameCardEmpty(
+ petName: selected.name,
+ onCreate: () => unawaited(context.push(AppRoutes.petCardCreate(selected.id))),
+ )
+ else
+ GestureDetector(
+ onTap: () => unawaited(context.push(AppRoutes.petCardDetail(card.id))),
+ child: CaptureBoundary(
+ controller: _capture,
+ child: NameCard(
+ pet: selected,
+ style: card.style,
+ onShare: () => unawaited(sharePetCard(context, pet: selected, card: card, capture: _capture)),
+ ),
+ ),
+ ),
],
],
);
@@ -102,66 +129,3 @@ class _RegisterPetCard extends StatelessWidget {
);
}
}
-
-/// 가로로 넘기는 아이 목록 — 고른 아이는 주황 테두리
-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/pet/domain/pet.dart b/flutter/lib/features/pet/domain/pet.dart
index fd8e732..9a461ff 100644
--- a/flutter/lib/features/pet/domain/pet.dart
+++ b/flutter/lib/features/pet/domain/pet.dart
@@ -31,6 +31,7 @@ class PetInput {
required this.weight,
required this.weightUnit,
this.photoPath,
+ this.microchip,
this.bloodType,
this.vet,
this.memo,
@@ -55,6 +56,9 @@ class PetInput {
/// 기기에 저장한 사진 경로 — 없으면 기본 아이콘
final String? photoPath;
+ /// 동물등록번호(마이크로칩) — 시안에서도 입력받지 않고 표시만 한다. 없으면 null
+ final String? microchip;
+
/// 값이 없으면 null ("미등록" 표기는 화면에서 한다)
final String? bloodType;
final String? vet;
@@ -81,6 +85,7 @@ class Pet extends PetInput {
required super.weight,
required super.weightUnit,
super.photoPath,
+ super.microchip,
super.bloodType,
super.vet,
super.memo,
@@ -100,6 +105,7 @@ class Pet extends PetInput {
weight: input.weight,
weightUnit: input.weightUnit,
photoPath: input.photoPath,
+ microchip: input.microchip,
bloodType: input.bloodType,
vet: input.vet,
memo: input.memo,
@@ -110,6 +116,26 @@ class Pet extends PetInput {
final String id;
+ /// 사진만 바꾼 입력값 — 명함 편집기의 카메라 버튼
+ PetInput withPhoto(String path) => PetInput(
+ name: name,
+ species: species,
+ breed: breed,
+ gender: gender,
+ neutered: neutered,
+ birthDate: birthDate,
+ weight: weight,
+ weightUnit: weightUnit,
+ photoPath: path,
+ microchip: microchip,
+ bloodType: bloodType,
+ vet: vet,
+ memo: memo,
+ allergies: allergies,
+ conditions: conditions,
+ watchConditions: watchConditions,
+ );
+
/// 3.2kg · 450g — 소수점 끝의 0 은 떼어 낸다
String get weightLabel {
final text = weight == weight.roundToDouble()
@@ -129,6 +155,7 @@ class Pet extends PetInput {
'weight': weight,
'weightUnit': weightUnit.name,
'photoPath': photoPath,
+ 'microchip': microchip,
'bloodType': bloodType,
'vet': vet,
'memo': memo,
@@ -148,6 +175,7 @@ class Pet extends PetInput {
weight: (json['weight']! as num).toDouble(),
weightUnit: WeightUnit.values.byName(json['weightUnit']! as String),
photoPath: json['photoPath'] as String?,
+ microchip: json['microchip'] as String?,
bloodType: json['bloodType'] as String?,
vet: json['vet'] as String?,
memo: json['memo'] as String?,
diff --git a/flutter/lib/features/pet/presentation/pages/pet_register_page.dart b/flutter/lib/features/pet/presentation/pages/pet_register_page.dart
index 67fc030..24fc830 100644
--- a/flutter/lib/features/pet/presentation/pages/pet_register_page.dart
+++ b/flutter/lib/features/pet/presentation/pages/pet_register_page.dart
@@ -18,6 +18,7 @@ import 'package:tma_app/features/pet/presentation/widgets/allergen_selector.dart
import 'package:tma_app/features/pet/presentation/widgets/pet_form_parts.dart';
import 'package:tma_app/features/pet/presentation/widgets/pet_photo_slot.dart';
import 'package:tma_app/features/pet/presentation/widgets/pet_pick_sheets.dart';
+import 'package:tma_app/features/pet_card/presentation/providers/pet_card_providers.dart';
import 'package:tma_app/shared/widgets/date_picker_sheet.dart';
import 'package:tma_app/shared/widgets/icons/rup_icon.dart';
import 'package:tma_app/shared/widgets/rup_button.dart';
@@ -44,18 +45,34 @@ List _splitList(String text) =>
String? _nullIfBlank(String text) => text.trim().isEmpty ? null : text.trim();
/// 반려동물 등록 / 수정 — [petId] 가 있으면 수정 모드
-class PetRegisterPage extends ConsumerStatefulWidget {
+class PetRegisterPage extends ConsumerWidget {
const PetRegisterPage({super.key, this.petId});
final String? petId;
@override
- ConsumerState createState() => _PetRegisterPageState();
+ Widget build(BuildContext context, WidgetRef ref) {
+ if (petId == null) return const _PetRegisterForm(editing: null);
+ // 수정은 목록을 다 불러온 뒤에 연다 — 불러오기 전에 열려 "새 등록"으로 오인하지 않게
+ final loaded = ref.watch(petListProvider).hasValue;
+ final pet = ref.watch(petByIdProvider(petId!));
+ if (!loaded || pet == null) return const Scaffold(body: SizedBox.shrink());
+ return _PetRegisterForm(editing: pet);
+ }
}
-class _PetRegisterPageState extends ConsumerState {
- late final Pet? _editing =
- widget.petId == null ? null : ref.read(petByIdProvider(widget.petId!));
+class _PetRegisterForm extends ConsumerStatefulWidget {
+ const _PetRegisterForm({required this.editing});
+
+ /// 수정 중인 아이 — 새로 등록할 때는 null
+ final Pet? editing;
+
+ @override
+ ConsumerState<_PetRegisterForm> createState() => _PetRegisterFormState();
+}
+
+class _PetRegisterFormState extends ConsumerState<_PetRegisterForm> {
+ late final Pet? _editing = widget.editing;
late final _name = TextEditingController(text: _editing?.name);
late final _weight = TextEditingController(
@@ -177,6 +194,8 @@ class _PetRegisterPageState extends ConsumerState {
weight: double.parse(_weight.text),
weightUnit: _unit,
photoPath: _photoPath,
+ // 등록번호는 이 화면에서 입력받지 않는다 — 수정 시 기존 값을 유지
+ microchip: _editing?.microchip,
bloodType: _nullIfBlank(_blood.text),
vet: _nullIfBlank(_vet.text),
memo: _nullIfBlank(_memo.text),
@@ -241,6 +260,8 @@ class _PetRegisterPageState extends ConsumerState {
);
if (!ok || !mounted) return;
await ref.read(petListProvider.notifier).deletePet(editing.id);
+ // 아이의 펫 명함도 함께 지운다 (일정은 일정 기능을 만들 때 함께 정리한다)
+ await ref.read(petCardsProvider.notifier).deleteForPet(editing.id);
ref.read(toastProvider.notifier).show('반려동물을 삭제했어요');
if (mounted) context.go(AppRoutes.myPage);
}
diff --git a/flutter/lib/features/pet/presentation/widgets/pet_picker.dart b/flutter/lib/features/pet/presentation/widgets/pet_picker.dart
new file mode 100644
index 0000000..936924d
--- /dev/null
+++ b/flutter/lib/features/pet/presentation/widgets/pet_picker.dart
@@ -0,0 +1,73 @@
+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/features/pet/domain/pet.dart';
+import 'package:tma_app/features/pet/presentation/widgets/pet_avatar.dart';
+
+/// 원형 사진 치수 — 시안 .rup-petpick__ring 58 / 아바타 46
+const double _kRingSize = 58;
+const double _kAvatarSize = 46;
+const double _kRingBorder = 2.5;
+
+/// 가로로 넘기는 아이 목록 (시안 .rup-petpick) — 고른 아이는 주황 테두리
+class PetPicker extends StatelessWidget {
+ const PetPicker({super.key, 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/pet_card/data/pet_card_repository.dart b/flutter/lib/features/pet_card/data/pet_card_repository.dart
new file mode 100644
index 0000000..1c93753
--- /dev/null
+++ b/flutter/lib/features/pet_card/data/pet_card_repository.dart
@@ -0,0 +1,110 @@
+import 'dart:convert';
+
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+import 'package:shared_preferences/shared_preferences.dart';
+import 'package:tma_app/core/storage/preferences_provider.dart';
+import 'package:tma_app/features/pet_card/domain/card_style.dart';
+import 'package:tma_app/features/pet_card/domain/pet_card.dart';
+
+/// 펫 명함 저장소 계약 — 백엔드 API 가 생기면 Dio 구현으로 바꾼다.
+abstract interface class PetCardRepository {
+ Future> fetchCards();
+
+ Future create(String petId, CardStyle style);
+
+ Future update(String cardId, CardStyle style);
+
+ Future delete(String cardId);
+
+ /// 아이별 대표 명함 (petId → cardId)
+ Future