fix(flutter): 펫 명함 화면 시안 점검 반영

- 새 명함은 그 아이의 고유색 한 가지로 시작한다 (CardStyle.forPet — 직접 선택 칸이 켜진다).
- 명함 이름이 길어도 윗줄(이름·성별·공유 버튼)이 깨지지 않게 하고, 공유 버튼에 누름 효과를 넣었다.
- 꾸미기에서 저장하고 돌아오면 상세히 보기로 돌아온다.
- 빈 명함 칸 누름 바탕, '기본' 칸은 골라도 그림자 없음, 명함 받기 안내 아이콘(QR) 등
  상세·관리·목록 화면의 간격과 줄 높이를 시안 값으로 맞췄다.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-29 01:24:24 +09:00
parent 81af93a813
commit 4173c64af8
10 changed files with 119 additions and 53 deletions
@@ -52,9 +52,12 @@ class CardStyle {
this.scrimTone = ScrimTone.light,
});
/// 새 명함의 기본값 — 첫 번째 헤더색(코랄), 흰 배경
/// 기본값 — 첫 번째 헤더색(코랄), 흰 배경
factory CardStyle.initial() => CardStyle(tint: kCardTints.first.a, tint2: kCardTints.first.b);
/// 새 명함 — 그 아이의 고유색 한 가지로 시작한다 (시안: tint = tint2 = pet.tint → 직접 선택 칸이 켜진다)
factory CardStyle.forPet(Color petTint) => CardStyle(tint: petTint, tint2: petTint);
/// 카드 상단 그라데이션 두 색
final Color tint;
final Color tint2;
@@ -68,6 +68,12 @@ class _PetCardDetailPageState extends ConsumerState<PetCardDetailPage> {
toast.show('대표 명함으로 설정했어요', icon: RupIconName.star);
}
/// 꾸미기 — 저장하고 돌아오면 상세히로 보여 준다 (시안 onSave → setMode('detail'))
Future<void> _edit(PetCard card) async {
final saved = await context.push<bool>(AppRoutes.petCardEdit(card.id));
if (saved == true && mounted) setState(() => _mode = CardViewMode.detail);
}
Future<void> _delete(PetCard card) async {
final ok = await showRupConfirm(
context,
@@ -111,6 +117,8 @@ class _PetCardDetailPageState extends ConsumerState<PetCardDetailPage> {
title: pet.name,
subtitle: '펫 명함',
displayFont: true,
// 시안 상세 머리 margin '4px 0 16px'
bottomGap: 16,
onBack: () => context.pop(),
trailing: SegToggle<CardViewMode>(
first: (CardViewMode.simple, CardViewMode.simple.label),
@@ -146,7 +154,7 @@ class _PetCardDetailPageState extends ConsumerState<PetCardDetailPage> {
child: _TextAction(
label: '카드 수정',
color: AppColors.ink2,
onTap: () => unawaited(context.push(AppRoutes.petCardEdit(card.id))),
onTap: () => unawaited(_edit(card)),
),
),
const SizedBox(width: 1, height: 13, child: ColoredBox(color: AppColors.line)),
@@ -236,7 +244,11 @@ class _TextAction extends StatelessWidget {
child: Text(
label,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium!.copyWith(fontWeight: FontWeight.w700, color: color),
style: Theme.of(context).textTheme.bodyMedium!.copyWith(
fontWeight: FontWeight.w700,
color: color,
height: AppTypography.normalHeight,
),
),
),
),
@@ -74,7 +74,12 @@ class _CardEditor extends ConsumerStatefulWidget {
}
class _CardEditorState extends ConsumerState<_CardEditor> {
late CardStyle _style = widget.card?.style ?? CardStyle.initial();
late CardStyle _style = widget.card?.style ?? _newStyle();
CardStyle _newStyle() {
final tint = ref.read(petByIdProvider(widget.petId))?.tint;
return tint == null ? CardStyle.initial() : CardStyle.forPet(tint);
}
bool _saving = false;
bool get _isEdit => widget.card != null;
@@ -138,7 +143,8 @@ class _CardEditorState extends ConsumerState<_CardEditor> {
if (_isEdit) {
await notifier.updateStyle(widget.card!.id, _style);
toast.show('펫 명함을 수정했어요');
if (mounted) context.pop();
// 저장했음을 알려 상세 화면이 "상세히" 로 돌아가게 한다
if (mounted) context.pop(true);
} else {
final card = await notifier.create(petId, _style);
toast.show('펫 명함을 만들었어요');
@@ -58,7 +58,7 @@ class _PetCardManagePageState extends ConsumerState<PetCardManagePage> {
child: ListView(
padding: EdgeInsets.fromLTRB(
AppSpacing.xl,
AppSpacing.sm,
AppSpacing.xs,
AppSpacing.xl,
MediaQuery.paddingOf(context).bottom + 40,
),
@@ -73,8 +73,10 @@ class _PetCardManagePageState extends ConsumerState<PetCardManagePage> {
onPressed: () => unawaited(_create(pets, cards.length)),
),
),
SizedBox(
height: _kChipHeight,
// 시안 .rup-petbar { padding: 2px 0 6px }
Container(
height: _kChipHeight + 8,
padding: const EdgeInsets.only(top: 2, bottom: 6),
child: ListView(
scrollDirection: Axis.horizontal,
children: [
@@ -67,7 +67,7 @@ class _PetCardsPageState extends ConsumerState<PetCardsPage> {
),
const SizedBox(height: AppSpacing.sm),
if (pet == null)
const EmptyMessage('등록된 반려동물이 없어요', padding: 40)
const EmptyMessage('등록된 반려동물이 없어요')
else ...[
padded(PetPicker(pets: pets, selectedId: pet.id, onSelect: (id) => setState(() => _selectedPetId = id))),
const SizedBox(height: 14),
@@ -195,6 +195,8 @@ class _ReceivedSectionState extends ConsumerState<_ReceivedSection> {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// 시안 .rup-section-title — 구분 띠 아래 22 와 제목 위 26 이 겹쳐 26 (좌우 4 는 줄 안에서)
const SizedBox(height: 4),
Row(
children: [
Expanded(
@@ -234,7 +236,7 @@ class _ReceivedSectionState extends ConsumerState<_ReceivedSection> {
const SizedBox(width: 5),
Pressable(
pressedScale: .96,
onTap: () => ref.read(toastProvider.notifier).show('명함 받기는 곧 열릴 예정이에요', icon: RupIconName.bell),
onTap: () => ref.read(toastProvider.notifier).show('명함 받기는 곧 열릴 예정이에요', icon: RupIconName.qrcode),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 13, vertical: AppSpacing.sm),
decoration: BoxDecoration(
@@ -252,9 +254,11 @@ class _ReceivedSectionState extends ConsumerState<_ReceivedSection> {
),
),
),
const SizedBox(width: AppSpacing.xs),
],
),
const SizedBox(height: 2),
// 시안: 제목 줄 아래 12 + 목록 위 2
const SizedBox(height: 14),
Padding(
padding: const EdgeInsets.fromLTRB(28, 64, 28, 48),
// 시안: 받은 명함이 한 장도 없으면 검색어와 상관없이 이 문구
@@ -153,8 +153,9 @@ class BackgroundColorRow extends StatelessWidget {
_Swatch(
label: '기본',
onTap: () => onChanged(style.withBackgroundColor(null)),
decoration: _swatchDecoration(selected: isDefault, color: Colors.white),
child: _DefaultLabel(selected: isDefault),
// 시안: '기본' 칸은 골라도 그림자가 없다
decoration: _swatchDecoration(selected: isDefault, color: Colors.white).copyWith(boxShadow: []),
child: _DefaultLabel(selected: isDefault, fontSize: 11),
),
for (final b in kCardBackgrounds)
_Swatch(
@@ -176,16 +177,19 @@ class BackgroundColorRow extends StatelessWidget {
}
class _DefaultLabel extends StatelessWidget {
const _DefaultLabel({required this.selected});
const _DefaultLabel({required this.selected, required this.fontSize});
final bool selected;
/// 시안: 색 줄 11, 이미지 줄 11.5
final double fontSize;
@override
Widget build(BuildContext context) {
return Text(
'기본',
style: Theme.of(context).textTheme.bodySmall!.copyWith(
fontSize: 11.5,
fontSize: fontSize,
fontWeight: FontWeight.w700,
color: selected ? AppColors.primary : AppColors.muted,
),
@@ -217,8 +221,13 @@ class BackgroundImageGrid extends StatelessWidget {
_ImageTile(
label: '기본',
selected: isDefault,
shadow: false,
onTap: () => onChanged(style.withBackgroundImage(null)),
child: Container(color: Colors.white, alignment: Alignment.center, child: _DefaultLabel(selected: isDefault)),
child: Container(
color: Colors.white,
alignment: Alignment.center,
child: _DefaultLabel(selected: isDefault, fontSize: 11.5),
),
),
for (final (i, path) in AppAssets.cardBackgrounds.indexed)
_ImageTile(
@@ -275,6 +284,7 @@ class _ImageTile extends StatelessWidget {
required this.onTap,
required this.child,
this.onRemove,
this.shadow = true,
});
final String label;
@@ -283,6 +293,9 @@ class _ImageTile extends StatelessWidget {
final Widget child;
final VoidCallback? onRemove;
/// 골랐을 때 주황 그림자 — '기본' 칸은 없다
final bool shadow;
@override
Widget build(BuildContext context) {
return Stack(
@@ -299,7 +312,7 @@ class _ImageTile extends StatelessWidget {
foregroundDecoration: _swatchDecoration(selected: selected).copyWith(boxShadow: []),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(_kSwatchRadius),
boxShadow: selected
boxShadow: selected && shadow
? [BoxShadow(color: AppColors.primary.withValues(alpha: .3), offset: const Offset(0, 2), blurRadius: 8)]
: null,
),
@@ -10,6 +10,7 @@ import 'package:tma_app/features/pet_card/domain/card_style.dart';
import 'package:tma_app/features/pet_card/domain/pet_card_facts.dart';
import 'package:tma_app/features/pet_card/presentation/widgets/card_background_image.dart';
import 'package:tma_app/shared/widgets/icons/rup_icon.dart';
import 'package:tma_app/shared/widgets/pressable.dart';
/// 성별 표시 색 — 시안 GENDER
const Map<PetGender, Color> kGenderColors = {
@@ -237,42 +238,46 @@ class _Body extends StatelessWidget {
children: [
Row(
children: [
Flexible(
child: Text(
pet.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
// 시안: 충북대체 일반 굵기 + 0.7px 외곽선 → 굵게 보인다
fontFamily: AppFonts.displayRegular,
fontWeight: FontWeight.w700,
fontSize: 22,
height: 1,
color: ink.strong,
),
// 이름·성별 묶음이 남은 폭을 모두 차지해 공유 버튼을 오른쪽 끝으로 민다 (시안 flex:1 빈칸)
Expanded(
child: Row(
children: [
Flexible(
child: Text(
pet.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
// 시안: 충북대체 일반 굵기 + 0.7px 외곽선 → 굵게 보인다
fontFamily: AppFonts.displayRegular,
fontWeight: FontWeight.w700,
fontSize: 22,
height: 1,
color: ink.strong,
),
),
),
const SizedBox(width: 7),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: gender.withValues(alpha: .12),
borderRadius: BorderRadius.circular(999),
),
child: Text(
pet.gender.label,
style: small.copyWith(fontSize: 11, fontWeight: FontWeight.w700, color: gender),
),
),
],
),
),
const SizedBox(width: 7),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: gender.withValues(alpha: .12),
borderRadius: BorderRadius.circular(999),
),
child: Text(
pet.gender.label,
style: small.copyWith(fontSize: 11, fontWeight: FontWeight.w700, color: gender),
),
),
const Spacer(),
if (onShare != null)
Semantics(
button: true,
label: '${pet.name} 명함 공유',
child: GestureDetector(
onTap: onShare,
child: SvgPicture.string(_kShareGlyph, width: 32, height: 32),
),
Pressable(
onTap: onShare,
pressedScale: .92,
semanticLabel: '${pet.name} 명함 공유',
child: SvgPicture.string(_kShareGlyph, width: 32, height: 32),
),
],
),
@@ -12,15 +12,31 @@ import 'package:tma_app/shared/widgets/pressable.dart';
const double _kMinHeight = 193;
const double _kRadius = 18;
/// 누르는 동안 바탕 (시안 .rup-namecard-empty:active)
const Color _kPressedBg = Color(0xFFFCF7F2);
/// 아직 펫 명함이 없는 아이 — 이름 + 안내 + "명함 만들기"
class NameCardEmpty extends StatelessWidget {
class NameCardEmpty extends StatefulWidget {
const NameCardEmpty({super.key, required this.petName, required this.onCreate});
final String petName;
final VoidCallback onCreate;
@override
State<NameCardEmpty> createState() => _NameCardEmptyState();
}
class _NameCardEmptyState extends State<NameCardEmpty> {
bool _pressed = false;
void _press(bool v) {
if (_pressed != v) setState(() => _pressed = v);
}
@override
Widget build(BuildContext context) {
final petName = widget.petName;
final onCreate = widget.onCreate;
final textTheme = Theme.of(context).textTheme;
return Semantics(
button: true,
@@ -29,10 +45,13 @@ class NameCardEmpty extends StatelessWidget {
cursor: SystemMouseCursors.click,
child: GestureDetector(
onTap: onCreate,
onTapDown: (_) => _press(true),
onTapUp: (_) => _press(false),
onTapCancel: () => _press(false),
child: DashedBorderBox(
color: AppColors.emptyCardBorder,
radius: _kRadius,
backgroundColor: AppColors.emptyCardBg,
backgroundColor: _pressed ? _kPressedBg : AppColors.emptyCardBg,
child: ConstrainedBox(
constraints: const BoxConstraints(minHeight: _kMinHeight, minWidth: double.infinity),
child: Padding(
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_svg/flutter_svg.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/features/pet/domain/pet.dart';
import 'package:tma_app/features/pet_card/domain/card_style.dart';
import 'package:tma_app/features/pet_card/presentation/widgets/name_card.dart';
@@ -83,6 +84,7 @@ class PetIdCard extends StatelessWidget {
style: Theme.of(context).textTheme.bodyMedium!.copyWith(
fontSize: 13,
fontWeight: FontWeight.w800,
height: AppTypography.normalHeight,
),
),
],
@@ -30,7 +30,7 @@ Future<String?> showPickPetSheet(BuildContext context, {required List<Pet> pets}
Text(
'어떤 반려동물의 카드를 만들까요?\n등록된 정보로 카드가 자동 채워져요.',
textAlign: TextAlign.center,
style: textTheme.bodySmall!.copyWith(fontSize: 12.5, color: AppColors.muted, height: 1.5),
style: textTheme.bodySmall!.copyWith(fontSize: 12.5, color: AppColors.muted, height: AppTypography.normalHeight),
),
const SizedBox(height: 18),
for (final (i, p) in pets.indexed) ...[