feat(flutter): 펫 명함 만들기·상세·관리·공유 구현

홈에서 아이를 등록한 뒤 이어지는 흐름이라 먼저 만든다.
명함은 기기에 저장하고(아이별 대표 명함 지정), 공유는 명함을 이미지로
그려 기기 공유 창과 QR 로 보낸다. 명함 받기는 서버가 필요해 안내만 둔다.

- 아이를 삭제하면 그 아이의 명함도 함께 지운다
- 수정 화면은 아이 목록을 다 불러온 뒤 연다 (새 등록으로 오인 방지)
- 의존성: share_plus, qr_flutter

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-28 21:49:21 +09:00
parent 2fab3af26d
commit 46bb77df9b
51 changed files with 4467 additions and 203 deletions
@@ -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(
@@ -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<Pet> onCreateCard;
@override
Widget build(BuildContext context, WidgetRef ref) {
ConsumerState<HomePetsSection> createState() => _HomePetsSectionState();
}
class _HomePetsSectionState extends ConsumerState<HomePetsSection> {
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<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,
),
),
],
),
),
),
],
],
),
);
}
}
+28
View File
@@ -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?,
@@ -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<String> _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<PetRegisterPage> 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<PetRegisterPage> {
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<PetRegisterPage> {
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<PetRegisterPage> {
);
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);
}
@@ -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<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,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<List<PetCard>> fetchCards();
Future<PetCard> create(String petId, CardStyle style);
Future<PetCard> update(String cardId, CardStyle style);
Future<void> delete(String cardId);
/// 아이별 대표 명함 (petId → cardId)
Future<Map<String, String>> fetchRepresentatives();
Future<void> setRepresentative(String petId, String cardId);
Future<void> clearRepresentative(String petId);
}
const String _kCardsKey = 'petCards.v1';
const String _kRepKey = 'petCards.rep.v1';
const String _kBgImagesKey = 'petCards.bgImages.v1';
/// 임시 구현 — 기기(SharedPreferences)에 JSON 으로 저장한다.
class LocalPetCardRepository implements PetCardRepository {
LocalPetCardRepository(this._prefs, {DateTime Function()? clock}) : _clock = clock ?? DateTime.now;
final SharedPreferences _prefs;
final DateTime Function() _clock;
List<PetCard> _read() {
final raw = _prefs.getString(_kCardsKey);
if (raw == null) return [];
return (jsonDecode(raw) as List<Object?>)
.whereType<Map<String, Object?>>()
.map(PetCard.fromJson)
.toList();
}
Future<void> _write(List<PetCard> cards) =>
_prefs.setString(_kCardsKey, jsonEncode([for (final c in cards) c.toJson()]));
Map<String, String> _readRep() {
final raw = _prefs.getString(_kRepKey);
if (raw == null) return {};
return (jsonDecode(raw) as Map<String, Object?>).map((k, v) => MapEntry(k, v! as String));
}
@override
Future<List<PetCard>> fetchCards() async => _read();
@override
Future<PetCard> create(String petId, CardStyle style) async {
final card = PetCard(id: 'card-${_clock().microsecondsSinceEpoch}', petId: petId, style: style);
await _write([..._read(), card]);
return card;
}
@override
Future<PetCard> update(String cardId, CardStyle style) async {
final cards = _read();
final updated = cards.firstWhere((c) => c.id == cardId).copyWith(style: style);
await _write([for (final c in cards) c.id == cardId ? updated : c]);
return updated;
}
@override
Future<void> delete(String cardId) async {
await _write(_read().where((c) => c.id != cardId).toList());
}
@override
Future<Map<String, String>> fetchRepresentatives() async => _readRep();
@override
Future<void> setRepresentative(String petId, String cardId) =>
_prefs.setString(_kRepKey, jsonEncode({..._readRep(), petId: cardId}));
@override
Future<void> clearRepresentative(String petId) =>
_prefs.setString(_kRepKey, jsonEncode({..._readRep()}..remove(petId)));
}
/// 명함 배경으로 쓰려고 추가한 내 사진 (최대 [kMaxUserBackgrounds]장) — 다음에 명함을 꾸밀 때도 남는다
const int kMaxUserBackgrounds = 5;
class CardBackgroundRepository {
CardBackgroundRepository(this._prefs);
final SharedPreferences _prefs;
List<String> load() => _prefs.getStringList(_kBgImagesKey) ?? const [];
Future<void> save(List<String> paths) => _prefs.setStringList(_kBgImagesKey, paths);
}
final petCardRepositoryProvider = Provider<PetCardRepository>((ref) {
return LocalPetCardRepository(ref.watch(sharedPreferencesProvider));
});
final cardBackgroundRepositoryProvider = Provider<CardBackgroundRepository>((ref) {
return CardBackgroundRepository(ref.watch(sharedPreferencesProvider));
});
@@ -0,0 +1,140 @@
import 'dart:ui' show Color;
import 'package:tma_app/core/utils/color_utils.dart';
/// 글자 뒤에 덮는 막의 색 — 밝은 막 / 어두운 막
enum ScrimTone { light, dark }
/// 카드 상단(헤더) 그라데이션 프리셋 — 시안 CARD_TINTS
class CardTint {
const CardTint(this.name, this.a, this.b);
final String name;
final Color a;
final Color b;
}
const List<CardTint> kCardTints = [
CardTint('코랄', Color(0xFFFF9B6A), Color(0xFFFFC59B)),
CardTint('블루', Color(0xFF5AA0E0), Color(0xFF9CCBF0)),
CardTint('퍼플', Color(0xFFB07CF0), Color(0xFFD3B4F7)),
CardTint('민트', Color(0xFF4FC79B), Color(0xFF93E0C2)),
CardTint('핑크', Color(0xFFFF8FB3), Color(0xFFFFC2D6)),
];
/// 명함 배경색 프리셋 — 시안 CARD_BGS
class CardBackground {
const CardBackground(this.name, this.color);
final String name;
final Color color;
}
const List<CardBackground> kCardBackgrounds = [
CardBackground('크림', Color(0xFFFFF7F1)),
CardBackground('민트', Color(0xFFECF7F2)),
CardBackground('라벤더', Color(0xFFF3EEFB)),
CardBackground('스카이', Color(0xFFEAF3FB)),
];
/// 직접 고른 헤더색의 두 번째 그라데이션 색을 만들 때 섞는 흰색 비율
const double kCustomTintLighten = .3;
/// 명함 꾸미기 값 — 이름·품종 등 내용은 반려동물 정보를 그대로 쓰고 모양만 저장한다
class CardStyle {
const CardStyle({
required this.tint,
required this.tint2,
this.nameBg,
this.nameBgImage,
this.textLight = false,
this.scrim = false,
this.scrimTone = ScrimTone.light,
});
/// 새 명함의 기본값 — 첫 번째 헤더색(코랄), 흰 배경
factory CardStyle.initial() => CardStyle(tint: kCardTints.first.a, tint2: kCardTints.first.b);
/// 카드 상단 그라데이션 두 색
final Color tint;
final Color tint2;
/// 명함 배경색 — null 이면 흰색(기본)
final Color? nameBg;
/// 명함 배경 이미지 — 에셋 경로(assets/…) 또는 기기에 저장한 사진 경로. 색과 함께 쓰지 않는다
final String? nameBgImage;
/// 밝은 글자 (어두운 배경용)
final bool textLight;
/// 글자 뒤에 은은한 막을 덮을지
final bool scrim;
final ScrimTone scrimTone;
bool get isPresetTint => kCardTints.any((t) => t.a == tint);
bool get isCustomBg => nameBg != null && !kCardBackgrounds.any((b) => b.color == nameBg);
CardStyle withTint(Color a, Color b) => _copy(tint: a, tint2: b);
/// 직접 고른 헤더색 — 두 번째 색은 조금 밝게
CardStyle withCustomTint(Color c) => _copy(tint: c, tint2: lighten(c, kCustomTintLighten));
/// 배경색을 고르면 배경 이미지는 해제된다 (null = 기본 흰색)
CardStyle withBackgroundColor(Color? c) =>
_copy(nameBg: () => c, nameBgImage: () => null);
/// 배경 이미지를 고르면 배경색은 해제된다 (null = 이미지 없음)
CardStyle withBackgroundImage(String? path) =>
_copy(nameBgImage: () => path, nameBg: path == null ? null : () => null);
CardStyle withTextLight(bool v) => _copy(textLight: v);
CardStyle withScrim(bool v) => _copy(scrim: v);
CardStyle withScrimTone(ScrimTone v) => _copy(scrimTone: v);
CardStyle _copy({
Color? tint,
Color? tint2,
Color? Function()? nameBg,
String? Function()? nameBgImage,
bool? textLight,
bool? scrim,
ScrimTone? scrimTone,
}) {
return CardStyle(
tint: tint ?? this.tint,
tint2: tint2 ?? this.tint2,
nameBg: nameBg != null ? nameBg() : this.nameBg,
nameBgImage: nameBgImage != null ? nameBgImage() : this.nameBgImage,
textLight: textLight ?? this.textLight,
scrim: scrim ?? this.scrim,
scrimTone: scrimTone ?? this.scrimTone,
);
}
Map<String, Object?> toJson() => {
'tint': hexOf(tint),
'tint2': hexOf(tint2),
'nameBg': nameBg == null ? null : hexOf(nameBg!),
'nameBgImage': nameBgImage,
'textLight': textLight,
'scrim': scrim,
'scrimTone': scrimTone.name,
};
factory CardStyle.fromJson(Map<String, Object?> json) {
final fallback = CardStyle.initial();
return CardStyle(
tint: colorFromHex(json['tint'] as String?) ?? fallback.tint,
tint2: colorFromHex(json['tint2'] as String?) ?? fallback.tint2,
nameBg: colorFromHex(json['nameBg'] as String?),
nameBgImage: json['nameBgImage'] as String?,
textLight: json['textLight'] as bool? ?? false,
scrim: json['scrim'] as bool? ?? false,
scrimTone: ScrimTone.values.asNameMap()[json['scrimTone']] ?? ScrimTone.light,
);
}
}
@@ -0,0 +1,23 @@
import 'package:tma_app/features/pet_card/domain/card_style.dart';
/// 한 사람이 만들 수 있는 펫 명함 수 — 시안 MAX_CARDS
const int kMaxPetCards = 10;
/// 펫 명함 — 어느 아이의 명함인지 + 꾸미기 값
class PetCard {
const PetCard({required this.id, required this.petId, required this.style});
final String id;
final String petId;
final CardStyle style;
PetCard copyWith({CardStyle? style}) => PetCard(id: id, petId: petId, style: style ?? this.style);
Map<String, Object?> toJson() => {'id': id, 'petId': petId, 'style': style.toJson()};
factory PetCard.fromJson(Map<String, Object?> json) => PetCard(
id: json['id']! as String,
petId: json['petId']! as String,
style: CardStyle.fromJson((json['style'] as Map<String, Object?>?) ?? const {}),
);
}
@@ -0,0 +1,33 @@
// 명함에 적히는 값 계산 — 순수 함수
/// 만 나이(년) — 이번 달 생일 날짜가 아직 안 왔으면 한 달을 덜 센다
int petAgeYears(DateTime birth, DateTime today) {
var months = (today.year - birth.year) * 12 + (today.month - birth.month);
if (today.day < birth.day) months--;
return months < 0 ? 0 : months ~/ 12;
}
/// 사람 나이 환산 — 1살 15세, 2살 24세, 이후 1년에 4세 (시안 humanAge). 1살 미만은 null
int? humanAgeOf(int petYears) {
if (petYears <= 0) return null;
if (petYears == 1) return 15;
return 24 + (petYears - 2) * 4;
}
/// 21.09.14 — 명함의 생일 표기
String shortBirth(DateTime d) {
String p(int n) => n.toString().padLeft(2, '0');
return '${p(d.year % 100)}.${p(d.month)}.${p(d.day)}';
}
/// 명함 코드 "0000-000" — 같은 시드면 항상 같은 코드 (시안 petCardCode).
/// 서버가 코드를 발급하게 되면 그 값으로 바꾼다.
String petCardCode(String seed) {
var h = 0;
for (final unit in seed.codeUnits) {
h = (h * 31 + unit) & 0xFFFFFFFF;
}
final a = (h % 10000).toString().padLeft(4, '0');
final b = ((h ~/ 10000) % 1000).toString().padLeft(3, '0');
return '$a-$b';
}
@@ -0,0 +1,245 @@
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/core/router/app_routes.dart';
import 'package:tma_app/core/theme/app_typography.dart';
import 'package:tma_app/features/pet/presentation/providers/pet_providers.dart';
import 'package:tma_app/features/pet_card/domain/pet_card.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/pet_id_card.dart';
import 'package:tma_app/features/pet_card/presentation/widgets/share_sheet.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/pressable.dart';
import 'package:tma_app/shared/widgets/rup_dialog.dart';
import 'package:tma_app/shared/widgets/rup_toast.dart';
import 'package:tma_app/shared/widgets/seg_toggle.dart';
import 'package:tma_app/shared/widgets/sub_page_header.dart';
/// 명함 보기 방식 — 간략히(명함만) / 상세히(건강 정보까지)
enum CardViewMode {
simple('간략히'),
detail('상세히');
const CardViewMode(this.label);
final String label;
}
/// 펫 명함 상세 — 보기 방식 전환, 공유, 꾸미기, 삭제, 대표 명함 지정
class PetCardDetailPage extends ConsumerStatefulWidget {
const PetCardDetailPage({super.key, required this.cardId});
final String cardId;
@override
ConsumerState<PetCardDetailPage> createState() => _PetCardDetailPageState();
}
class _PetCardDetailPageState extends ConsumerState<PetCardDetailPage> {
CardViewMode _mode = CardViewMode.detail;
final _capture = CaptureController();
Future<void> _share(String petName) {
return showPetCardShareSheet(
context,
petName: petName,
codeSeed: widget.cardId,
capture: _capture.capturePng,
who: TextSpan(children: [
TextSpan(text: '${_mode.label} 모드', style: const TextStyle(color: AppColors.primary600)),
const TextSpan(text: ' 펫 명함으로 공유돼요'),
]),
);
}
Future<void> _toggleRepresentative(PetCard card, bool isRep) async {
final toast = ref.read(toastProvider.notifier);
if (isRep) {
toast.show('이미 대표 명함이에요', icon: RupIconName.star);
return;
}
await ref.read(petCardsProvider.notifier).setRepresentative(card);
toast.show('대표 명함으로 설정했어요', icon: RupIconName.star);
}
Future<void> _delete(PetCard card) async {
final ok = await showRupConfirm(
context,
title: '이 펫 명함을 삭제할까요?',
message: '삭제한 명함은 다시 되돌릴 수 없어요.\n반려동물 정보는 그대로 유지돼요.',
confirmLabel: '삭제하기',
destructive: true,
layout: RupConfirmLayout.row,
);
if (!ok || !mounted) return;
// 먼저 화면을 닫아 지워진 명함을 그리지 않게 한다
context.pop();
await ref.read(petCardsProvider.notifier).delete(card.id);
ref.read(toastProvider.notifier).show('펫 명함을 삭제했어요');
}
@override
Widget build(BuildContext context) {
final card = ref.watch(cardByIdProvider(widget.cardId));
final pet = card == null ? null : ref.watch(petByIdProvider(card.petId));
if (card == null || pet == null) return const Scaffold(body: SizedBox.shrink());
final isRep = ref.watch(representativeCardProvider(card.petId))?.id == card.id;
final textTheme = Theme.of(context).textTheme;
final muted = textTheme.bodySmall!.copyWith(color: AppColors.muted);
return Scaffold(
body: SafeArea(
bottom: false,
child: SingleChildScrollView(
padding: EdgeInsets.fromLTRB(
AppSpacing.xl,
AppSpacing.xs,
AppSpacing.xl,
MediaQuery.paddingOf(context).bottom + 40,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
SubPageHeader(
title: pet.name,
subtitle: '펫 명함',
displayFont: true,
onBack: () => context.pop(),
trailing: SegToggle<CardViewMode>(
first: (CardViewMode.simple, CardViewMode.simple.label),
second: (CardViewMode.detail, CardViewMode.detail.label),
value: _mode,
onChanged: (m) => setState(() => _mode = m),
),
),
AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
child: CaptureBoundary(
key: ValueKey(_mode),
controller: _capture,
child: _mode == CardViewMode.detail
? PetIdCard(
pet: pet,
style: card.style,
isRepresentative: isRep,
onToggleRepresentative: () => unawaited(_toggleRepresentative(card, isRep)),
)
: Padding(
padding: const EdgeInsets.only(top: 18),
child: NameCard(pet: pet, style: card.style),
),
),
),
const SizedBox(height: 32),
_ShareButton(onPressed: () => unawaited(_share(pet.name))),
const SizedBox(height: 34),
Row(
children: [
Expanded(
child: _TextAction(
label: '카드 수정',
color: AppColors.ink2,
onTap: () => unawaited(context.push(AppRoutes.petCardEdit(card.id))),
),
),
const SizedBox(width: 1, height: 13, child: ColoredBox(color: AppColors.line)),
Expanded(
child: _TextAction(
label: '삭제',
semanticLabel: '명함 삭제하기',
color: AppColors.catHospital,
onTap: () => unawaited(_delete(card)),
),
),
],
),
const SizedBox(height: 26),
Text.rich(
TextSpan(children: [
const TextSpan(text: '지금 보이는 '),
TextSpan(
text: '${_mode.label} 모드',
style: const TextStyle(fontWeight: FontWeight.w700, color: AppColors.ink2),
),
const TextSpan(text: ' 카드가 그대로 공유돼요.\n이미지로 저장하면 앱이 없는 분께도 공유할 수 있어요.'),
]),
textAlign: TextAlign.center,
style: muted.copyWith(height: 1.6),
),
],
),
),
),
);
}
}
/// "카드 공유하기" — 모서리 16, 주황 그림자
class _ShareButton extends StatelessWidget {
const _ShareButton({required this.onPressed});
final VoidCallback onPressed;
@override
Widget build(BuildContext context) {
return Pressable(
onTap: onPressed,
child: Container(
padding: const EdgeInsets.all(AppSpacing.lg),
decoration: BoxDecoration(
color: AppColors.primary,
borderRadius: BorderRadius.circular(AppSpacing.lg),
border: Border.all(color: AppColors.primaryBorder, width: 1.5),
boxShadow: [
BoxShadow(color: AppColors.primary.withValues(alpha: .4), offset: const Offset(0, 10), blurRadius: 24),
],
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const RupIcon(RupIconName.share, size: 19, color: Colors.white),
const SizedBox(width: 7),
Text('카드 공유하기', style: AppTypography.button(16, color: Colors.white)),
],
),
),
);
}
}
class _TextAction extends StatelessWidget {
const _TextAction({required this.label, required this.color, required this.onTap, this.semanticLabel});
final String label;
final String? semanticLabel;
final Color color;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return Semantics(
button: true,
label: semanticLabel ?? label,
excludeSemantics: true,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 7),
child: Text(
label,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium!.copyWith(fontWeight: FontWeight.w700, color: color),
),
),
),
);
}
}
@@ -0,0 +1,336 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:image_picker/image_picker.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/pet/data/pet_photo_store.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_card/data/pet_card_repository.dart';
import 'package:tma_app/features/pet_card/domain/card_style.dart';
import 'package:tma_app/features/pet_card/domain/pet_card.dart';
import 'package:tma_app/features/pet_card/presentation/providers/pet_card_providers.dart';
import 'package:tma_app/features/pet_card/presentation/widgets/card_style_controls.dart';
import 'package:tma_app/features/pet_card/presentation/widgets/color_pick_sheet.dart';
import 'package:tma_app/features/pet_card/presentation/widgets/name_card.dart';
import 'package:tma_app/shared/widgets/icons/rup_icon.dart';
import 'package:tma_app/shared/widgets/rup_button.dart';
import 'package:tma_app/shared/widgets/rup_logo.dart';
import 'package:tma_app/shared/widgets/rup_nav_header.dart';
import 'package:tma_app/shared/widgets/rup_toast.dart';
/// 꾸미기 항목 사이 간격 — 시안 .rup-field--cust { margin-bottom: 26px }
const double _kSectionGap = 26;
const double _kPhotoMaxSide = 1600;
const int _kPhotoQuality = 85;
const int _kMaxRecentColors = 8;
/// 이 앱을 켜 둔 동안 최근에 고른 색 (색 고르기 시트의 "최근 사용한 색상")
class RecentColorsNotifier extends Notifier<List<Color>> {
@override
List<Color> build() => const [];
void push(Color c) => state = [c, ...state.where((x) => x != c)].take(_kMaxRecentColors).toList();
}
final recentColorsProvider = NotifierProvider<RecentColorsNotifier, List<Color>>(RecentColorsNotifier.new);
/// 펫 명함 만들기 / 꾸미기 — 내용은 반려동물 정보를 그대로 쓰고 모양만 고른다
class PetCardEditorPage extends ConsumerWidget {
/// 새 명함 — [petId] 아이의 명함을 만든다
const PetCardEditorPage.create({super.key, required String this.petId}) : cardId = null;
/// 기존 명함 꾸미기
const PetCardEditorPage.edit({super.key, required String this.cardId}) : petId = null;
final String? petId;
final String? cardId;
@override
Widget build(BuildContext context, WidgetRef ref) {
// 명함·반려동물 목록을 다 불러온 뒤에 폼을 만든다 (주소로 바로 들어와도 빈 값으로 시작하지 않게)
final loaded = ref.watch(petCardsProvider).hasValue && ref.watch(petListProvider).hasValue;
final card = cardId == null ? null : ref.watch(cardByIdProvider(cardId!));
final resolvedPetId = petId ?? card?.petId;
if (!loaded || resolvedPetId == null) return const Scaffold(body: SizedBox.shrink());
return _CardEditor(petId: resolvedPetId, card: card);
}
}
class _CardEditor extends ConsumerStatefulWidget {
const _CardEditor({required this.petId, required this.card});
final String petId;
/// 꾸미기 중인 기존 명함 — 새로 만들 때는 null
final PetCard? card;
@override
ConsumerState<_CardEditor> createState() => _CardEditorState();
}
class _CardEditorState extends ConsumerState<_CardEditor> {
late CardStyle _style = widget.card?.style ?? CardStyle.initial();
bool _saving = false;
bool get _isEdit => widget.card != null;
String get _petId => widget.petId;
Future<XFile?> _pickImage() => ImagePicker().pickImage(
source: ImageSource.gallery,
maxWidth: _kPhotoMaxSide,
maxHeight: _kPhotoMaxSide,
imageQuality: _kPhotoQuality,
);
/// 사진의 카메라 버튼 — 반려동물 사진 자체를 바꾼다 (모든 명함에 반영)
Future<void> _changePhoto(Pet pet) async {
final file = await _pickImage();
if (file == null) return;
final path = await ref.read(petPhotoStoreProvider).persist(file);
await ref.read(petListProvider.notifier).updatePet(pet.id, pet.withPhoto(path));
}
Future<void> _addBackground() async {
final toast = ref.read(toastProvider.notifier);
if (ref.read(userBackgroundsProvider).length >= kMaxUserBackgrounds) {
toast.show('배경 이미지는 5개까지 추가할 수 있어요', icon: RupIconName.bell);
return;
}
final file = await _pickImage();
if (file == null) return;
final path = await ref.read(petPhotoStoreProvider).persist(file);
await ref.read(userBackgroundsProvider.notifier).add(path);
if (mounted) setState(() => _style = _style.withBackgroundImage(path));
}
Future<void> _removeBackground(String path) async {
await ref.read(userBackgroundsProvider.notifier).remove(path);
if (mounted && _style.nameBgImage == path) {
setState(() => _style = _style.withBackgroundImage(null));
}
}
Future<void> _pickColor({required bool tint}) async {
final color = await showColorPickSheet(
context,
title: tint ? '명함 헤더 색상' : '명함 배경 색상',
initial: tint ? _style.tint : (_style.nameBg ?? Colors.white),
recent: ref.read(recentColorsProvider),
);
if (color == null || !mounted) return;
ref.read(recentColorsProvider.notifier).push(color);
setState(() => _style = tint ? _style.withCustomTint(color) : _style.withBackgroundColor(color));
}
Future<void> _confirm() async {
final petId = _petId;
if (_saving) return;
setState(() => _saving = true);
final notifier = ref.read(petCardsProvider.notifier);
final toast = ref.read(toastProvider.notifier);
try {
if (_isEdit) {
await notifier.updateStyle(widget.card!.id, _style);
toast.show('펫 명함을 수정했어요');
if (mounted) context.pop();
} else {
final card = await notifier.create(petId, _style);
toast.show('펫 명함을 만들었어요');
// 만든 명함의 상세로 — 뒤로 가면 명함을 만들기 시작한 화면으로 돌아간다
if (mounted) context.pushReplacement(AppRoutes.petCardDetail(card.id));
}
} finally {
if (mounted) setState(() => _saving = false);
}
}
@override
Widget build(BuildContext context) {
final pet = ref.watch(petByIdProvider(_petId));
final textTheme = Theme.of(context).textTheme;
final label = textTheme.bodyMedium!.copyWith(fontSize: 13, fontWeight: FontWeight.w700, color: AppColors.ink2);
final note = textTheme.bodySmall!.copyWith(fontSize: 11.5, color: AppColors.muted, height: 1.5);
Widget section(String title, Widget child) => Padding(
padding: const EdgeInsets.only(bottom: _kSectionGap),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(padding: const EdgeInsets.only(bottom: 7), child: Text(title, style: label)),
child,
],
),
);
return Scaffold(
body: SafeArea(
bottom: false,
child: pet == null
? const SizedBox.shrink()
: SingleChildScrollView(
padding: EdgeInsets.fromLTRB(
AppSpacing.xl,
AppSpacing.xs,
AppSpacing.xl,
MediaQuery.paddingOf(context).bottom + 40,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
RupNavHeader(title: _isEdit ? '명함 꾸미기' : '명함 만들기', onBack: () => context.pop()),
const SizedBox(height: 18),
_Preview(pet: pet, style: _style, onEditPhoto: () => unawaited(_changePhoto(pet))),
const SizedBox(height: AppSpacing.sm),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const RupIcon(RupIconName.camera, size: 13, stroke: 2, color: AppColors.primary),
const SizedBox(width: 5),
Flexible(child: Text('사진의 카메라 버튼을 눌러 우리 아이 사진을 바꿀 수 있어요.', style: note)),
],
),
const SizedBox(height: 22),
section(
'카드 상단 색상',
TintSwatchRow(
style: _style,
onChanged: (s) => setState(() => _style = s),
onCustom: () => unawaited(_pickColor(tint: true)),
),
),
section(
'명함 배경 색상',
BackgroundColorRow(
style: _style,
onChanged: (s) => setState(() => _style = s),
onCustom: () => unawaited(_pickColor(tint: false)),
),
),
section(
'명함 배경 이미지',
Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
BackgroundImageGrid(
style: _style,
userImages: ref.watch(userBackgroundsProvider),
onChanged: (s) => setState(() => _style = s),
onAdd: () => unawaited(_addBackground()),
onRemove: (p) => unawaited(_removeBackground(p)),
),
const SizedBox(height: AppSpacing.sm),
Text(
ref.watch(userBackgroundsProvider).length >= kMaxUserBackgrounds
? '내 배경 이미지는 5개를 모두 채웠어요. ×로 지우면 새 이미지를 추가할 수 있어요.'
: '+ 를 눌러 내 사진을 배경으로 추가할 수 있어요 (최대 5개).',
style: note,
),
const SizedBox(height: 14),
ScrimControls(style: _style, onChanged: (s) => setState(() => _style = s)),
],
),
),
section(
'글자 색상',
TextColorToggle(
light: _style.textLight,
onChanged: (v) => setState(() => _style = _style.withTextLight(v)),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(AppSpacing.xs, 0, AppSpacing.xs, _kSectionGap),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Padding(
padding: EdgeInsets.only(top: 1),
child: RupIcon(RupIconName.shield, size: 14, stroke: 2, color: AppColors.accent),
),
const SizedBox(width: 6),
Expanded(
child: Text(
'이름·품종·몸무게·건강 정보 등은 등록 정보가 그대로 사용돼요. 내용 수정은 반려동물 정보에서 해주세요.',
style: note.copyWith(fontSize: 12),
),
),
],
),
),
RupButton(
label: _isEdit ? '변경사항 저장' : '펫 명함 만들기',
onPressed: _saving ? null : _confirm,
),
],
),
),
),
);
}
}
/// 실시간 미리보기 — 카드 상단 색 띠 + 명함
class _Preview extends StatelessWidget {
const _Preview({required this.pet, required this.style, required this.onEditPhoto});
final Pet pet;
final CardStyle style;
final VoidCallback onEditPhoto;
@override
Widget build(BuildContext context) {
return Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(22),
border: Border.all(color: AppColors.line),
boxShadow: const [
BoxShadow(color: AppColors.shadowMd, offset: Offset(0, 4), blurRadius: 14),
BoxShadow(color: AppColors.shadowMdSoft, offset: Offset(0, 1), blurRadius: 4),
],
),
clipBehavior: Clip.antiAlias,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
AnimatedContainer(
duration: AppDurations.fast,
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 15),
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [style.tint, style.tint2],
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const RupLogo(height: 21, white: true),
const SizedBox(height: 3),
Text(
'PET ID CARD',
style: Theme.of(context).textTheme.bodySmall!.copyWith(
fontSize: 10,
fontWeight: FontWeight.w600,
letterSpacing: .5,
color: Colors.white.withValues(alpha: .92),
),
),
],
),
),
Container(
color: AppColors.surface,
padding: const EdgeInsets.all(13),
child: NameCard(pet: pet, style: style, embedded: true, onEditPhoto: onEditPhoto),
),
],
),
);
}
}
@@ -0,0 +1,186 @@
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/core/router/app_routes.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/domain/pet_card.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/pick_pet_sheet.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_button.dart';
import 'package:tma_app/shared/widgets/rup_toast.dart';
import 'package:tma_app/shared/widgets/sub_page_header.dart';
const double _kChipHeight = 45;
/// 명함 관리 — 내가 만든 명함 전체, 아이별로 거르기, 새 명함 만들기
class PetCardManagePage extends ConsumerStatefulWidget {
const PetCardManagePage({super.key});
@override
ConsumerState<PetCardManagePage> createState() => _PetCardManagePageState();
}
class _PetCardManagePageState extends ConsumerState<PetCardManagePage> {
/// null 이면 전체
String? _filterPetId;
Future<void> _create(List<Pet> pets, int count) async {
if (count >= kMaxPetCards) {
ref.read(toastProvider.notifier).show('펫 명함은 최대 $kMaxPetCards장까지예요', icon: RupIconName.alert);
return;
}
final petId = await showPickPetSheet(context, pets: pets);
if (petId == null || !mounted) return;
unawaited(context.push(AppRoutes.petCardCreate(petId)));
}
@override
Widget build(BuildContext context) {
final pets = ref.watch(petListProvider).value ?? const <Pet>[];
final cards = ref.watch(myCardsProvider);
final shown = _filterPetId == null ? cards : cards.where((c) => c.petId == _filterPetId).toList();
final filterPet = pets.where((p) => p.id == _filterPetId).firstOrNull;
final textTheme = Theme.of(context).textTheme;
return Scaffold(
body: SafeArea(
bottom: false,
child: ListView(
padding: EdgeInsets.fromLTRB(
AppSpacing.xl,
AppSpacing.sm,
AppSpacing.xl,
MediaQuery.paddingOf(context).bottom + 40,
),
children: [
SubPageHeader(
title: '명함 관리',
subtitle: '내 아이 명함 ${cards.length}장 · 최대 $kMaxPetCards장',
displayFont: true,
onBack: () => context.pop(),
trailing: RupSoftPillButton(
label: '명함 만들기',
onPressed: () => unawaited(_create(pets, cards.length)),
),
),
SizedBox(
height: _kChipHeight,
child: ListView(
scrollDirection: Axis.horizontal,
children: [
_FilterChip(
label: '전체',
selected: _filterPetId == null,
onTap: () => setState(() => _filterPetId = null),
),
for (final p in pets) ...[
const SizedBox(width: 10),
_FilterChip(
label: p.name,
pet: p,
selected: _filterPetId == p.id,
onTap: () => setState(() => _filterPetId = p.id),
),
],
],
),
),
const SizedBox(height: AppSpacing.lg),
if (shown.isEmpty)
Padding(
padding: const EdgeInsets.only(top: 72),
child: Text(
filterPet == null ? '아직 만든 아이 명함이 없어요.' : '아직 만든 ${filterPet.name} 명함이 없어요.',
textAlign: TextAlign.center,
style: textTheme.bodyMedium!.copyWith(fontSize: 15.5, color: AppColors.muted),
),
),
for (final (i, card) in shown.indexed) ...[
if (i > 0) const SizedBox(height: AppSpacing.md),
_ManagedCard(card: card, pet: pets.firstWhere((p) => p.id == card.petId)),
],
],
),
),
);
}
}
class _ManagedCard extends StatefulWidget {
const _ManagedCard({required this.card, required this.pet});
final PetCard card;
final Pet pet;
@override
State<_ManagedCard> createState() => _ManagedCardState();
}
class _ManagedCardState extends State<_ManagedCard> {
final _capture = CaptureController();
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: () => unawaited(context.push(AppRoutes.petCardDetail(widget.card.id))),
child: CaptureBoundary(
controller: _capture,
child: NameCard(
pet: widget.pet,
style: widget.card.style,
onShare: () => unawaited(sharePetCard(context, pet: widget.pet, card: widget.card, capture: _capture)),
),
),
);
}
}
/// 아이별 거르기 칩 (시안 .rup-petchip)
class _FilterChip extends StatelessWidget {
const _FilterChip({required this.label, required this.selected, required this.onTap, this.pet});
final String label;
final bool selected;
final VoidCallback onTap;
final Pet? pet;
@override
Widget build(BuildContext context) {
return Semantics(
button: true,
selected: selected,
child: GestureDetector(
onTap: onTap,
child: AnimatedContainer(
duration: AppDurations.fast,
padding: EdgeInsets.fromLTRB(pet == null ? 14 : 6, 6, 14, 6),
decoration: BoxDecoration(
color: selected ? AppColors.surface2 : AppColors.surface,
borderRadius: BorderRadius.circular(AppRadius.pill),
border: Border.all(color: selected ? AppColors.primary100 : AppColors.line, width: 1.5),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (pet != null) ...[PetAvatar(pet: pet!, size: 30), const SizedBox(width: AppSpacing.sm)],
Text(
label,
style: Theme.of(context).textTheme.bodyMedium!.copyWith(fontSize: 14, fontWeight: FontWeight.w600),
),
],
),
),
),
);
}
}
@@ -0,0 +1,348 @@
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/core/router/app_routes.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/presentation/providers/pet_providers.dart';
import 'package:tma_app/features/pet/presentation/widgets/pet_picker.dart';
import 'package:tma_app/features/pet_card/domain/pet_card.dart';
import 'package:tma_app/features/pet_card/presentation/providers/pet_card_providers.dart';
import 'package:tma_app/features/pet_card/presentation/widgets/crown_mark.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/features/pet_card/presentation/widgets/share_sheet.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/pressable.dart';
import 'package:tma_app/shared/widgets/rup_toast.dart';
import 'package:tma_app/shared/widgets/section_title.dart';
const double _kSearchSize = 38;
const Duration _kSearchAnim = Duration(milliseconds: 340);
const Cubic _kSearchCurve = Cubic(.4, 0, .2, 1);
/// 탭바 뒤로 콘텐츠가 가려지지 않게 두는 여유
const double _kBottomGap = 30;
/// 펫 명함 탭 — 우리 아이 대표 명함 + 받은(보관된) 명함
class PetCardsPage extends ConsumerStatefulWidget {
const PetCardsPage({super.key});
@override
ConsumerState<PetCardsPage> createState() => _PetCardsPageState();
}
class _PetCardsPageState extends ConsumerState<PetCardsPage> {
String? _selectedPetId;
final _capture = CaptureController();
@override
Widget build(BuildContext context) {
final pets = ref.watch(petListProvider).value ?? const <Pet>[];
final pet = pets.where((p) => p.id == _selectedPetId).firstOrNull ?? pets.firstOrNull;
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.md, bottom: MediaQuery.paddingOf(context).bottom + _kBottomGap),
children: [
padded(
Padding(
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.xs),
child: SectionTitle(
title: '우리 아이 명함',
sub: true,
trailing: '전체 보기',
onTrailingTap: () => unawaited(context.push(AppRoutes.petCardManage)),
),
),
),
const SizedBox(height: AppSpacing.sm),
if (pet == null)
const EmptyMessage('등록된 반려동물이 없어요', padding: 40)
else ...[
padded(PetPicker(pets: pets, selectedId: pet.id, onSelect: (id) => setState(() => _selectedPetId = id))),
const SizedBox(height: 14),
padded(_RepresentativeCard(pet: pet, capture: _capture)),
],
const DividerBand(),
padded(const _ReceivedSection()),
],
),
);
}
}
/// 고른 아이의 대표 명함 — 없으면 "명함 만들기" 안내
class _RepresentativeCard extends ConsumerWidget {
const _RepresentativeCard({required this.pet, required this.capture});
final Pet pet;
final CaptureController capture;
@override
Widget build(BuildContext context, WidgetRef ref) {
final card = ref.watch(representativeCardProvider(pet.id));
if (card == null) {
return NameCardEmpty(
petName: pet.name,
onCreate: () => unawaited(context.push(AppRoutes.petCardCreate(pet.id))),
);
}
final textTheme = Theme.of(context).textTheme;
return GestureDetector(
onTap: () => unawaited(context.push(AppRoutes.petCardDetail(card.id))),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(2, 0, 2, AppSpacing.md),
child: Row(
children: [
const CrownMark(color: AppColors.primary600),
const SizedBox(width: AppSpacing.xs),
Expanded(
child: Text(
'${pet.name} 대표 명함',
style: textTheme.bodySmall!.copyWith(fontWeight: FontWeight.w800, color: AppColors.primary600),
),
),
Text('탭하면 상세보기', style: textTheme.bodySmall!.copyWith(fontWeight: FontWeight.w600, color: AppColors.muted)),
const SizedBox(width: 1),
const RupIcon(RupIconName.chevR, size: 13, color: AppColors.muted),
],
),
),
CaptureBoundary(
controller: capture,
child: NameCard(
pet: pet,
style: card.style,
onShare: () => unawaited(sharePetCard(context, pet: pet, card: card, capture: capture)),
),
),
],
),
);
}
}
/// 목록에서 명함 공유 — 명함 아래 안내에 아이 이름을 쓴다
Future<void> sharePetCard(
BuildContext context, {
required Pet pet,
required PetCard card,
required CaptureController capture,
}) {
return showPetCardShareSheet(
context,
petName: pet.name,
codeSeed: card.id,
capture: capture.capturePng,
who: TextSpan(children: [
TextSpan(text: pet.name, style: const TextStyle(color: AppColors.primary600)),
const TextSpan(text: '의 펫 명함으로 공유돼요'),
]),
);
}
/// 보관된(받은) 명함 — 받기 기능은 서버 연동 후 연다
class _ReceivedSection extends ConsumerStatefulWidget {
const _ReceivedSection();
@override
ConsumerState<_ReceivedSection> createState() => _ReceivedSectionState();
}
class _ReceivedSectionState extends ConsumerState<_ReceivedSection> {
bool _searchOpen = false;
final _query = TextEditingController();
final _focus = FocusNode();
@override
void dispose() {
_query.dispose();
_focus.dispose();
super.dispose();
}
void _openSearch() {
setState(() => _searchOpen = true);
Future<void>.delayed(const Duration(milliseconds: 280), () {
if (mounted) _focus.requestFocus();
});
}
void _closeSearch() => setState(() {
_searchOpen = false;
_query.clear();
_focus.unfocus();
});
@override
Widget build(BuildContext context) {
final textTheme = Theme.of(context).textTheme;
// 받은 명함 저장소는 명함 받기(서버 조회)와 함께 만든다 — 지금은 항상 비어 있다
const receivedCount = 0;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
Expanded(
child: SizedBox(
height: _kSearchSize,
child: Stack(
alignment: Alignment.centerLeft,
children: [
Padding(
padding: const EdgeInsets.only(left: AppSpacing.xs),
child: AnimatedOpacity(
duration: const Duration(milliseconds: 180),
opacity: _searchOpen ? 0 : 1,
child: Text(
'보관된 명함 $receivedCount',
style: textTheme.bodyMedium!.copyWith(fontSize: 14, fontWeight: FontWeight.w600, color: AppColors.muted),
),
),
),
Align(
alignment: Alignment.centerRight,
child: LayoutBuilder(
builder: (context, constraints) => _SearchBox(
open: _searchOpen,
width: _searchOpen ? constraints.maxWidth : _kSearchSize,
controller: _query,
focusNode: _focus,
onOpen: _openSearch,
onClose: _closeSearch,
),
),
),
],
),
),
),
const SizedBox(width: 5),
Pressable(
pressedScale: .96,
onTap: () => ref.read(toastProvider.notifier).show('명함 받기는 곧 열릴 예정이에요', icon: RupIconName.bell),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 13, vertical: AppSpacing.sm),
decoration: BoxDecoration(
color: AppColors.surface,
borderRadius: BorderRadius.circular(AppSpacing.md),
border: Border.all(color: AppColors.line, width: 1.5),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const RupGlyphIcon(RupGlyph.qr, size: 18, color: AppColors.primary),
const SizedBox(width: 7),
Text('명함 받기', style: AppTypography.button(13.5, color: AppColors.ink)),
],
),
),
),
],
),
const SizedBox(height: 2),
Padding(
padding: const EdgeInsets.fromLTRB(28, 64, 28, 48),
// 시안: 받은 명함이 한 장도 없으면 검색어와 상관없이 이 문구
child: Text(
'받은 명함이 없어요.',
textAlign: TextAlign.center,
style: textTheme.bodyMedium!.copyWith(fontSize: 14, color: AppColors.muted),
),
),
],
);
}
}
/// 오른쪽 돋보기 버튼에서 왼쪽으로 펼쳐지는 검색창 (시안 .rup-searchx)
class _SearchBox extends StatelessWidget {
const _SearchBox({
required this.open,
required this.width,
required this.controller,
required this.focusNode,
required this.onOpen,
required this.onClose,
});
final bool open;
final double width;
final TextEditingController controller;
final FocusNode focusNode;
final VoidCallback onOpen;
final VoidCallback onClose;
@override
Widget build(BuildContext context) {
return Semantics(
button: !open,
label: open ? null : '명함 검색',
child: GestureDetector(
onTap: open ? null : onOpen,
child: AnimatedContainer(
duration: _kSearchAnim,
curve: _kSearchCurve,
width: width,
height: _kSearchSize,
padding: EdgeInsets.only(left: open ? AppSpacing.md : 0, right: open ? AppSpacing.sm : 0),
decoration: BoxDecoration(
color: AppColors.surface,
borderRadius: BorderRadius.circular(11),
border: Border.all(color: open ? AppColors.primary : AppColors.line, width: 1.5),
boxShadow: open ? [BoxShadow(color: AppColors.primary.withValues(alpha: .14), spreadRadius: 3)] : null,
),
clipBehavior: Clip.hardEdge,
child: Row(
mainAxisAlignment: open ? MainAxisAlignment.start : MainAxisAlignment.center,
children: [
const RupIcon(RupIconName.search, size: 20, color: AppColors.ink2),
if (open) ...[
const SizedBox(width: AppSpacing.sm),
Expanded(
child: TextField(
controller: controller,
focusNode: focusNode,
style: Theme.of(context).textTheme.bodyLarge!.copyWith(fontSize: 14.5, height: 1.2),
decoration: InputDecoration.collapsed(
hintText: '명함 검색',
hintStyle: Theme.of(context).textTheme.bodyLarge!.copyWith(fontSize: 14.5, color: AppColors.muted),
),
),
),
Semantics(
button: true,
label: '검색 닫기',
child: GestureDetector(
onTap: onClose,
child: Container(
width: 26,
height: 26,
decoration: BoxDecoration(color: AppColors.surface2, borderRadius: BorderRadius.circular(AppSpacing.sm)),
alignment: Alignment.center,
child: const RupIcon(RupIconName.x, size: 17, stroke: 2.2, color: AppColors.ink2),
),
),
),
],
],
),
),
),
);
}
}
@@ -0,0 +1,117 @@
import 'package:flutter_riverpod/flutter_riverpod.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_card/data/pet_card_repository.dart';
import 'package:tma_app/features/pet_card/domain/card_style.dart';
import 'package:tma_app/features/pet_card/domain/pet_card.dart';
/// 내가 만든 명함 + 아이별 대표 명함
class PetCardState {
const PetCardState({required this.cards, required this.representatives});
final List<PetCard> cards;
final Map<String, String> representatives;
PetCardState copyWith({List<PetCard>? cards, Map<String, String>? representatives}) =>
PetCardState(
cards: cards ?? this.cards,
representatives: representatives ?? this.representatives,
);
}
class PetCardNotifier extends AsyncNotifier<PetCardState> {
PetCardRepository get _repo => ref.read(petCardRepositoryProvider);
@override
Future<PetCardState> build() async {
final repo = ref.watch(petCardRepositoryProvider);
return PetCardState(
cards: await repo.fetchCards(),
representatives: await repo.fetchRepresentatives(),
);
}
PetCardState get _current => state.value ?? const PetCardState(cards: [], representatives: {});
void _emit(PetCardState next) {
if (ref.mounted) state = AsyncData(next);
}
Future<PetCard> create(String petId, CardStyle style) async {
final card = await _repo.create(petId, style);
_emit(_current.copyWith(cards: [..._current.cards, card]));
return card;
}
Future<void> updateStyle(String cardId, CardStyle style) async {
final card = await _repo.update(cardId, style);
_emit(_current.copyWith(cards: [for (final c in _current.cards) c.id == cardId ? card : c]));
}
Future<void> delete(String cardId) async {
final card = _current.cards.where((c) => c.id == cardId).firstOrNull;
await _repo.delete(cardId);
final reps = {..._current.representatives};
if (card != null && reps[card.petId] == cardId) {
reps.remove(card.petId);
await _repo.clearRepresentative(card.petId);
}
_emit(PetCardState(
cards: _current.cards.where((c) => c.id != cardId).toList(),
representatives: reps,
));
}
/// 아이를 삭제하면 그 아이의 명함도 함께 지운다
Future<void> deleteForPet(String petId) async {
for (final card in _current.cards.where((c) => c.petId == petId).toList()) {
await delete(card.id);
}
}
Future<void> setRepresentative(PetCard card) async {
await _repo.setRepresentative(card.petId, card.id);
_emit(_current.copyWith(representatives: {..._current.representatives, card.petId: card.id}));
}
}
final petCardsProvider = AsyncNotifierProvider<PetCardNotifier, PetCardState>(PetCardNotifier.new);
/// 지금 등록된 아이들의 명함만 (지워진 아이의 명함은 숨긴다)
final myCardsProvider = Provider<List<PetCard>>((ref) {
final cards = ref.watch(petCardsProvider).value?.cards ?? const <PetCard>[];
final petIds = {for (final p in ref.watch(petListProvider).value ?? const <Pet>[]) p.id};
return cards.where((c) => petIds.contains(c.petId)).toList();
});
final cardByIdProvider = Provider.family<PetCard?, String>((ref, cardId) {
return ref.watch(myCardsProvider).where((c) => c.id == cardId).firstOrNull;
});
/// 아이의 대표 명함 — 지정한 것이 없으면 그 아이의 첫 명함
final representativeCardProvider = Provider.family<PetCard?, String>((ref, petId) {
final cards = ref.watch(myCardsProvider).where((c) => c.petId == petId).toList();
final repId = ref.watch(petCardsProvider).value?.representatives[petId];
return cards.where((c) => c.id == repId).firstOrNull ?? cards.firstOrNull;
});
/// 명함 배경으로 추가한 내 사진
class UserBackgroundsNotifier extends Notifier<List<String>> {
@override
List<String> build() => ref.watch(cardBackgroundRepositoryProvider).load();
Future<void> add(String path) async {
if (state.length >= kMaxUserBackgrounds || state.contains(path)) return;
state = [...state, path];
await ref.read(cardBackgroundRepositoryProvider).save(state);
}
Future<void> remove(String path) async {
state = state.where((p) => p != path).toList();
await ref.read(cardBackgroundRepositoryProvider).save(state);
}
}
final userBackgroundsProvider = NotifierProvider<UserBackgroundsNotifier, List<String>>(
UserBackgroundsNotifier.new,
);
@@ -0,0 +1,20 @@
import 'package:flutter/widgets.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:tma_app/features/pet/presentation/widgets/pet_photo_image.dart';
/// 명함 배경 이미지 — 기본 제공 에셋(assets/…svg) 또는 기기에 저장한 내 사진
class CardBackgroundImage extends StatelessWidget {
const CardBackgroundImage({super.key, required this.path});
final String path;
@override
Widget build(BuildContext context) {
if (path.startsWith('assets/')) {
return path.endsWith('.svg')
? SvgPicture.asset(path, fit: BoxFit.cover)
: Image.asset(path, fit: BoxFit.cover);
}
return PetPhotoImage(path: path);
}
}
@@ -0,0 +1,489 @@
import 'dart:math' as math;
import 'package:flutter/material.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/features/pet_card/data/pet_card_repository.dart';
import 'package:tma_app/features/pet_card/domain/card_style.dart';
import 'package:tma_app/features/pet_card/presentation/widgets/card_background_image.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/rup_switch.dart';
const double _kSwatch = 46;
const double _kSwatchRadius = 14;
const double _kImageTileHeight = 50;
const int _kImageColumns = 6;
/// "직접 선택" 칸의 무지개 (시안 conic-gradient(from .5turn, …))
const List<Color> _kRainbow = [
Color(0xFFFF6B6B), Color(0xFFF0B03E), Color(0xFF4FC79B), Color(0xFF5AA0E0),
Color(0xFFB07CF0), Color(0xFFFF8FB3), Color(0xFFFF6B6B),
];
BoxDecoration _swatchDecoration({required bool selected, Color? color, Gradient? gradient}) {
return BoxDecoration(
color: color,
gradient: gradient,
borderRadius: BorderRadius.circular(_kSwatchRadius),
border: Border.all(
color: selected ? AppColors.primary : AppColors.line2,
width: selected ? 3 : 1.5,
),
boxShadow: selected
? [BoxShadow(color: AppColors.primary.withValues(alpha: .3), offset: const Offset(0, 2), blurRadius: 8)]
: null,
);
}
class _Swatch extends StatelessWidget {
const _Swatch({required this.label, required this.decoration, required this.onTap, this.child});
final String label;
final BoxDecoration decoration;
final VoidCallback onTap;
final Widget? child;
@override
Widget build(BuildContext context) {
return Semantics(
button: true,
label: label,
child: GestureDetector(
onTap: onTap,
child: Container(
width: _kSwatch,
height: _kSwatch,
decoration: decoration,
alignment: Alignment.center,
child: child,
),
),
);
}
}
/// 직접 고르기 칸 — 아직 안 골랐으면 무지개 + +, 골랐으면 그 색 + 연필
class _CustomSwatch extends StatelessWidget {
const _CustomSwatch({required this.selected, required this.onTap, this.fill});
final bool selected;
final VoidCallback onTap;
/// 고른 색(또는 그라데이션). 없으면 무지개
final Gradient? fill;
@override
Widget build(BuildContext context) {
return _Swatch(
label: '직접 선택',
onTap: onTap,
decoration: _swatchDecoration(
selected: selected,
gradient: fill ??
const SweepGradient(colors: _kRainbow, transform: GradientRotation(math.pi / 2)),
),
child: Container(
width: 21,
height: 21,
decoration: const BoxDecoration(
color: Color(0xEBFFFFFF),
shape: BoxShape.circle,
boxShadow: [BoxShadow(color: Color(0x33000000), offset: Offset(0, 1), blurRadius: 3)],
),
alignment: Alignment.center,
child: RupIcon(selected ? RupIconName.edit : RupIconName.plus, size: 13, stroke: 2.2, color: AppColors.ink),
),
);
}
}
/// 카드 상단 색상 — 프리셋 5 + 직접 선택
class TintSwatchRow extends StatelessWidget {
const TintSwatchRow({super.key, required this.style, required this.onChanged, required this.onCustom});
final CardStyle style;
final ValueChanged<CardStyle> onChanged;
final VoidCallback onCustom;
@override
Widget build(BuildContext context) {
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
for (final t in kCardTints)
_Swatch(
label: t.name,
onTap: () => onChanged(style.withTint(t.a, t.b)),
decoration: _swatchDecoration(
selected: style.tint == t.a,
gradient: LinearGradient(begin: Alignment.topLeft, end: Alignment.bottomRight, colors: [t.a, t.b]),
),
child: style.tint == t.a
? const RupIcon(RupIconName.check, size: 19, stroke: 2.4, color: Colors.white)
: null,
),
_CustomSwatch(
selected: !style.isPresetTint,
onTap: onCustom,
fill: style.isPresetTint
? null
: LinearGradient(begin: Alignment.topLeft, end: Alignment.bottomRight, colors: [style.tint, style.tint2]),
),
],
);
}
}
/// 명함 배경 색상 — 기본(흰색) + 프리셋 4 + 직접 선택
class BackgroundColorRow extends StatelessWidget {
const BackgroundColorRow({super.key, required this.style, required this.onChanged, required this.onCustom});
final CardStyle style;
final ValueChanged<CardStyle> onChanged;
final VoidCallback onCustom;
@override
Widget build(BuildContext context) {
final isDefault = style.nameBg == null;
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
_Swatch(
label: '기본',
onTap: () => onChanged(style.withBackgroundColor(null)),
decoration: _swatchDecoration(selected: isDefault, color: Colors.white),
child: _DefaultLabel(selected: isDefault),
),
for (final b in kCardBackgrounds)
_Swatch(
label: b.name,
onTap: () => onChanged(style.withBackgroundColor(b.color)),
decoration: _swatchDecoration(selected: style.nameBg == b.color, color: b.color),
child: style.nameBg == b.color
? const RupIcon(RupIconName.check, size: 19, stroke: 2.4, color: AppColors.primary)
: null,
),
_CustomSwatch(
selected: style.isCustomBg,
onTap: onCustom,
fill: style.isCustomBg ? LinearGradient(colors: [style.nameBg!, style.nameBg!]) : null,
),
],
);
}
}
class _DefaultLabel extends StatelessWidget {
const _DefaultLabel({required this.selected});
final bool selected;
@override
Widget build(BuildContext context) {
return Text(
'기본',
style: Theme.of(context).textTheme.bodySmall!.copyWith(
fontSize: 11.5,
fontWeight: FontWeight.w700,
color: selected ? AppColors.primary : AppColors.muted,
),
);
}
}
/// 명함 배경 이미지 — 기본 + 제공 이미지 4 + 내 사진(최대 5, 지우기) + 추가
class BackgroundImageGrid extends StatelessWidget {
const BackgroundImageGrid({
super.key,
required this.style,
required this.userImages,
required this.onChanged,
required this.onAdd,
required this.onRemove,
});
final CardStyle style;
final List<String> userImages;
final ValueChanged<CardStyle> onChanged;
final VoidCallback onAdd;
final ValueChanged<String> onRemove;
@override
Widget build(BuildContext context) {
final isDefault = style.nameBgImage == null;
final tiles = <Widget>[
_ImageTile(
label: '기본',
selected: isDefault,
onTap: () => onChanged(style.withBackgroundImage(null)),
child: Container(color: Colors.white, alignment: Alignment.center, child: _DefaultLabel(selected: isDefault)),
),
for (final (i, path) in AppAssets.cardBackgrounds.indexed)
_ImageTile(
label: '기본 배경 ${i + 1}',
selected: style.nameBgImage == path,
onTap: () => onChanged(style.withBackgroundImage(path)),
child: CardBackgroundImage(path: path),
),
for (final (i, path) in userImages.indexed)
_ImageTile(
label: '내 배경 ${i + 1}',
selected: style.nameBgImage == path,
onTap: () => onChanged(style.withBackgroundImage(path)),
onRemove: () => onRemove(path),
child: CardBackgroundImage(path: path),
),
if (userImages.length < kMaxUserBackgrounds)
Semantics(
button: true,
label: '이미지 추가',
child: GestureDetector(
onTap: onAdd,
child: const DashedBorderBox(
color: AppColors.line,
radius: _kSwatchRadius,
backgroundColor: AppColors.surface,
child: SizedBox(
height: _kImageTileHeight,
child: Center(child: RupIcon(RupIconName.plus, size: 20, stroke: 2.2, color: AppColors.muted)),
),
),
),
),
];
return LayoutBuilder(
builder: (context, constraints) {
const gap = AppSpacing.sm;
final w = (constraints.maxWidth - gap * (_kImageColumns - 1)) / _kImageColumns;
return Wrap(
spacing: gap,
runSpacing: gap,
children: [for (final t in tiles) SizedBox(width: w, height: _kImageTileHeight, child: t)],
);
},
);
}
}
class _ImageTile extends StatelessWidget {
const _ImageTile({
required this.label,
required this.selected,
required this.onTap,
required this.child,
this.onRemove,
});
final String label;
final bool selected;
final VoidCallback onTap;
final Widget child;
final VoidCallback? onRemove;
@override
Widget build(BuildContext context) {
return Stack(
clipBehavior: Clip.none,
fit: StackFit.expand,
children: [
Semantics(
button: true,
selected: selected,
label: label,
child: GestureDetector(
onTap: onTap,
child: Container(
foregroundDecoration: _swatchDecoration(selected: selected).copyWith(boxShadow: []),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(_kSwatchRadius),
boxShadow: selected
? [BoxShadow(color: AppColors.primary.withValues(alpha: .3), offset: const Offset(0, 2), blurRadius: 8)]
: null,
),
child: ClipRRect(borderRadius: BorderRadius.circular(_kSwatchRadius), child: child),
),
),
),
if (onRemove != null)
Positioned(
top: -6,
right: -6,
child: Semantics(
button: true,
label: '$label 삭제',
child: GestureDetector(
onTap: onRemove,
child: Container(
width: 20,
height: 20,
decoration: BoxDecoration(
color: AppColors.ink,
shape: BoxShape.circle,
border: Border.all(color: AppColors.surface, width: 2),
boxShadow: const [BoxShadow(color: Color(0x40000000), offset: Offset(0, 1), blurRadius: 4)],
),
alignment: Alignment.center,
child: const RupIcon(RupIconName.x, size: 11, stroke: 2.6, color: Colors.white),
),
),
),
),
],
);
}
}
/// 글자 잘 보이게 하기 — 스위치 + (켜면) 밝은/어두운 막 전환
class ScrimControls extends StatelessWidget {
const ScrimControls({super.key, required this.style, required this.onChanged});
final CardStyle style;
final ValueChanged<CardStyle> onChanged;
@override
Widget build(BuildContext context) {
final textTheme = Theme.of(context).textTheme;
final dark = style.scrimTone == ScrimTone.dark;
return Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('글자 잘 보이게 하기', style: textTheme.bodyMedium!.copyWith(fontWeight: FontWeight.w700, height: 1.3)),
const SizedBox(height: 2),
Text(
'배경 위에 은은한 색을 덮어줘요',
style: textTheme.bodySmall!.copyWith(fontSize: 11.5, color: AppColors.muted),
),
],
),
),
const SizedBox(width: 10),
RupSwitch(
value: style.scrim,
label: '글자 잘 보이게 하기',
onChanged: (v) => onChanged(style.withScrim(v)),
),
if (style.scrim) ...[
const SizedBox(width: 18),
Semantics(
button: true,
label: '덮어쓰는 색 — ${dark ? '검정' : '흰색'}',
child: GestureDetector(
onTap: () => onChanged(style.withScrimTone(dark ? ScrimTone.light : ScrimTone.dark)),
child: AnimatedRotation(
turns: dark ? .5 : 0,
duration: const Duration(milliseconds: 200),
child: AnimatedContainer(
duration: const Duration(milliseconds: 280),
width: 34,
height: 34,
decoration: BoxDecoration(
color: dark ? const Color(0xFF1A1512) : Colors.white,
shape: BoxShape.circle,
border: Border.all(color: AppColors.line2, width: 1.5),
),
),
),
),
),
],
],
);
}
}
/// 글자 색상 — 어두운 글자 / 밝은 글자 (미끄러지는 흰 표시)
class TextColorToggle extends StatelessWidget {
const TextColorToggle({super.key, required this.light, required this.onChanged});
final bool light;
final ValueChanged<bool> onChanged;
@override
Widget build(BuildContext context) {
final style = Theme.of(context).textTheme.bodyMedium!.copyWith(fontWeight: FontWeight.w700, height: 1.2);
Widget option(bool isLight, String name, Color swatch) {
final on = light == isLight;
return Expanded(
child: Semantics(
button: true,
selected: on,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => onChanged(isLight),
child: SizedBox(
height: 42,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
AnimatedScale(
scale: on ? 1.08 : 1,
duration: const Duration(milliseconds: 200),
child: Container(
width: 18,
height: 18,
decoration: BoxDecoration(
color: swatch,
shape: BoxShape.circle,
border: Border.all(color: AppColors.line2, width: 1.5),
),
),
),
const SizedBox(width: AppSpacing.sm),
AnimatedDefaultTextStyle(
duration: const Duration(milliseconds: 180),
style: style.copyWith(color: on ? AppColors.primary : AppColors.muted),
child: Text(name),
),
],
),
),
),
),
);
}
return Container(
padding: const EdgeInsets.all(AppSpacing.xs),
decoration: BoxDecoration(
color: const Color(0xFFF5F1EC),
borderRadius: BorderRadius.circular(AppSpacing.lg),
border: Border.all(color: AppColors.line2, width: 1.5),
),
child: Stack(
children: [
Positioned.fill(
child: AnimatedAlign(
duration: const Duration(milliseconds: 220),
curve: const Cubic(.4, 0, .2, 1),
alignment: light ? Alignment.centerRight : Alignment.centerLeft,
child: FractionallySizedBox(
widthFactor: .5,
heightFactor: 1,
child: DecoratedBox(
decoration: BoxDecoration(
color: AppColors.surface,
borderRadius: BorderRadius.circular(AppSpacing.md),
boxShadow: const [BoxShadow(color: Color(0x14000000), offset: Offset(0, 1), blurRadius: 4)],
),
),
),
),
),
Row(
children: [
option(false, '어두운 글자', const Color(0xFF2A2A2A)),
option(true, '밝은 글자', Colors.white),
],
),
],
),
);
}
}
@@ -0,0 +1,315 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.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/color_utils.dart';
import 'package:tma_app/shared/widgets/rup_sheet.dart';
const double _kPadHeight = 150;
const double _kThumb = 18;
const double _kHueHeight = 20;
const int _kMaxRecent = 8;
/// 추천 색 — 지금 색조에서 채도 70%, 밝기만 달리한 네 가지 (시안 tuned)
const List<double> _kTunedLightness = [.34, .52, .70, .86];
/// 앱 안에서 여는 색 고르기 시트 (시안 ColorPickSheet) — 고르면 Color 를 돌려준다
Future<Color?> showColorPickSheet(
BuildContext context, {
required String title,
required Color initial,
List<Color> recent = const [],
}) {
return showRupSheet<Color>(
context,
title: title,
flushBottom: true,
builder: (context) => _ColorPickBody(initial: initial, recent: recent),
);
}
class _ColorPickBody extends StatefulWidget {
const _ColorPickBody({required this.initial, required this.recent});
final Color initial;
final List<Color> recent;
@override
State<_ColorPickBody> createState() => _ColorPickBodyState();
}
class _ColorPickBodyState extends State<_ColorPickBody> {
late HSVColor _hsv = HSVColor.fromColor(widget.initial);
late final _hex = TextEditingController(text: hexOf(widget.initial));
Color get _color => _hsv.toColor();
@override
void dispose() {
_hex.dispose();
super.dispose();
}
void _set(HSVColor hsv, {bool syncText = true}) {
setState(() => _hsv = hsv);
if (syncText) _hex.text = hexOf(hsv.toColor());
}
void _pick(Color c) => _set(HSVColor.fromColor(c));
void _onPad(Offset local, Size size) {
final s = (local.dx / size.width).clamp(0.0, 1.0);
final v = 1 - (local.dy / size.height).clamp(0.0, 1.0);
_set(_hsv.withSaturation(s).withValue(v));
}
void _onHue(Offset local, double width) {
final h = (local.dx / width).clamp(0.0, 1.0) * 359;
_set(_hsv.withHue(h));
}
void _onHexChanged(String text) {
final c = colorFromHex(text);
if (c != null) _set(HSVColor.fromColor(c), syncText: false);
}
@override
Widget build(BuildContext context) {
final textTheme = Theme.of(context).textTheme;
final hint = textTheme.bodySmall!.copyWith(fontSize: 12.5, color: AppColors.muted);
final tuned = [
for (final l in _kTunedLightness) HSLColor.fromAHSL(1, _hsv.hue, .7, l).toColor(),
];
return SingleChildScrollView(
// 바닥 버튼이 좌우 여백 밖까지 채워지도록 잘라내지 않는다
clipBehavior: Clip.none,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
AnimatedContainer(
duration: const Duration(milliseconds: 200),
width: 42,
height: 42,
decoration: BoxDecoration(
color: _color,
borderRadius: BorderRadius.circular(13),
border: Border.all(color: AppColors.line2, width: 1.5),
),
),
const SizedBox(width: 10),
Expanded(
child: Container(
height: 42,
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.md),
decoration: BoxDecoration(
color: AppColors.surface,
borderRadius: BorderRadius.circular(AppSpacing.md),
border: Border.all(color: AppColors.line2, width: 1.5),
),
alignment: Alignment.centerLeft,
child: TextField(
controller: _hex,
onChanged: _onHexChanged,
inputFormatters: [
LengthLimitingTextInputFormatter(7),
FilteringTextInputFormatter.allow(RegExp('[#0-9a-fA-F]')),
],
textCapitalization: TextCapitalization.characters,
style: textTheme.bodyMedium!.copyWith(fontSize: 14, fontWeight: FontWeight.w700, letterSpacing: .4),
decoration: const InputDecoration.collapsed(hintText: '#RRGGBB'),
),
),
),
],
),
const SizedBox(height: AppSpacing.lg),
// 채도(가로) · 명도(세로) 패드
LayoutBuilder(
builder: (context, constraints) {
final size = Size(constraints.maxWidth, _kPadHeight);
return GestureDetector(
onPanDown: (d) => _onPad(d.localPosition, size),
onPanUpdate: (d) => _onPad(d.localPosition, size),
child: Container(
height: _kPadHeight,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(14),
border: Border.all(color: AppColors.line2, width: 1.5),
gradient: LinearGradient(
colors: [Colors.white, HSVColor.fromAHSV(1, _hsv.hue, 1, 1).toColor()],
),
),
child: Stack(
clipBehavior: Clip.none,
children: [
Positioned.fill(
child: DecoratedBox(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12.5),
gradient: const LinearGradient(
begin: Alignment.bottomCenter,
end: Alignment.topCenter,
colors: [Colors.black, Colors.transparent],
),
),
),
),
Positioned(
left: _hsv.saturation * size.width - _kThumb / 2,
top: (1 - _hsv.value) * _kPadHeight - _kThumb / 2,
child: const _Thumb(),
),
],
),
),
);
},
),
const SizedBox(height: AppSpacing.md),
// 색조 막대
LayoutBuilder(
builder: (context, constraints) {
final w = constraints.maxWidth;
return Semantics(
slider: true,
label: '색조',
value: '${_hsv.hue.round()}',
child: GestureDetector(
onPanDown: (d) => _onHue(d.localPosition, w),
onPanUpdate: (d) => _onHue(d.localPosition, w),
child: SizedBox(
height: _kHueHeight,
child: Stack(
clipBehavior: Clip.none,
children: [
Positioned.fill(
child: DecoratedBox(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(AppRadius.pill),
gradient: const LinearGradient(
colors: [
Color(0xFFFF0000), Color(0xFFFFFF00), Color(0xFF00FF00), Color(0xFF00FFFF),
Color(0xFF0000FF), Color(0xFFFF00FF), Color(0xFFFF0000),
],
),
),
),
),
Positioned(
left: (_hsv.hue / 359) * (w - _kHueHeight),
top: 0,
child: const _Thumb(size: _kHueHeight),
),
],
),
),
),
);
},
),
Padding(
padding: const EdgeInsets.only(top: 18, bottom: AppSpacing.sm),
child: Text('추천 색상', style: hint),
),
_SwatchGrid(colors: tuned, columns: 4, gap: AppSpacing.sm, selected: _color, onPick: _pick),
if (widget.recent.isNotEmpty) ...[
Padding(
padding: const EdgeInsets.only(top: 26, bottom: AppSpacing.sm),
child: Text('최근 사용한 색상', style: hint),
),
_SwatchGrid(
colors: widget.recent.take(_kMaxRecent).toList(),
columns: _kMaxRecent,
gap: 7,
selected: _color,
onPick: _pick,
),
],
const SizedBox(height: 22),
RupSheetBleedButton(
label: '이 색으로 정하기',
onPressed: () => Navigator.of(context).pop(_color),
),
],
),
);
}
}
class _Thumb extends StatelessWidget {
const _Thumb({this.size = _kThumb});
final double size;
@override
Widget build(BuildContext context) {
return IgnorePointer(
child: Container(
width: size,
height: size,
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: 2.5),
boxShadow: const [BoxShadow(color: Color(0x59000000), offset: Offset(0, 1), blurRadius: 4)],
),
),
);
}
}
class _SwatchGrid extends StatelessWidget {
const _SwatchGrid({
required this.colors,
required this.columns,
required this.gap,
required this.selected,
required this.onPick,
});
final List<Color> colors;
final int columns;
final double gap;
final Color selected;
final ValueChanged<Color> onPick;
@override
Widget build(BuildContext context) {
return GridView.count(
crossAxisCount: columns,
mainAxisSpacing: gap,
crossAxisSpacing: gap,
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
padding: EdgeInsets.zero,
children: [
for (final c in colors)
Semantics(
button: true,
label: hexOf(c),
child: GestureDetector(
onTap: () => onPick(c),
child: AnimatedScale(
scale: c == selected ? 1.06 : 1,
duration: const Duration(milliseconds: 160),
curve: const Cubic(.34, 1.6, .5, 1),
child: DecoratedBox(
decoration: BoxDecoration(
color: c,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: c == selected ? AppColors.primary : AppColors.line2,
width: c == selected ? 3 : 1.5,
),
),
),
),
),
),
],
);
}
}
@@ -0,0 +1,26 @@
import 'package:flutter/widgets.dart';
import 'package:flutter_svg/flutter_svg.dart';
/// 왕관 선 아이콘 — 대표 명함 표시 (시안 CrownMark)
class CrownMark extends StatelessWidget {
const CrownMark({super.key, required this.color, this.size = 14});
final Color color;
final double size;
static const String _svg = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" '
'stroke-linejoin="round" stroke-linecap="round">'
'<path d="M3.2 7.4 7.4 10.6 12 4.8l4.6 5.8 4.2-3.2-1.5 9.2a1.1 1.1 0 0 1-1.08.9H5.78a1.1 1.1 0 0 1-1.08-.9L3.2 7.4Z"/>'
'<path d="M5.5 20.2h13"/></svg>';
@override
Widget build(BuildContext context) {
return SvgPicture.string(
_svg,
width: size,
height: size,
theme: SvgTheme(currentColor: color),
excludeFromSemantics: true,
);
}
}
@@ -0,0 +1,333 @@
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/theme/app_shadows.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/presentation/widgets/pet_photo_image.dart';
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';
/// 성별 표시 색 — 시안 GENDER
const Map<PetGender, Color> kGenderColors = {
PetGender.male: Color(0xFF5B9BD0),
PetGender.female: Color(0xFFE07AA0),
};
/// 등록번호가 없을 때 표기
const String kUnregistered = '미등록';
// 명함 치수 — 시안 .rup-namecard
const double _kPhotoWidth = 150;
const double _kPhotoMinHeight = 180;
const double _kRadius = 18;
const double _kEmbeddedRadius = 14;
const double _kPhotoRadius = 14;
const Color _kEmptyPhotoBorder = Color(0xFFC8C8C8);
const Color _kEmptyPhotoFill = Color(0x0A000000); // rgba(0,0,0,.04)
/// 공유 버튼 원 (시안 ShareGlyph — 35 뷰박스)
const String _kShareGlyph =
'<svg viewBox="0 0 35 35" fill="none"><rect x="0.5" y="0.5" width="34" height="34" rx="17" fill="#FBFAF9"/>'
'<rect x="0.5" y="0.5" width="34" height="34" rx="17" stroke="#EEEEEE"/>'
'<path d="M15.0737 16.5442L19.9263 14.0444M15.0737 18.4558L19.9263 20.9556M10.8828 17.5C10.8828 18.085 11.1152 18.646 11.5289 19.0597C11.9425 19.4733 12.5035 19.7057 13.0885 19.7057C13.6735 19.7057 14.2346 19.4733 14.6482 19.0597C15.0619 18.646 15.2943 18.085 15.2943 17.5C15.2943 16.915 15.0619 16.354 14.6482 15.9403C14.2346 15.5267 13.6735 15.2943 13.0885 15.2943C12.5035 15.2943 11.9425 15.5267 11.5289 15.9403C11.1152 16.354 10.8828 16.915 10.8828 17.5ZM19.7057 13.0885C19.7057 13.6735 19.9381 14.2346 20.3518 14.6482C20.7654 15.0619 21.3265 15.2943 21.9115 15.2943C22.4965 15.2943 23.0575 15.0619 23.4711 14.6482C23.8848 14.2346 24.1172 13.6735 24.1172 13.0885C24.1172 12.5035 23.8848 11.9425 23.4711 11.5289C23.0575 11.1152 22.4965 10.8828 21.9115 10.8828C21.3265 10.8828 20.7654 11.1152 20.3518 11.5289C19.9381 11.9425 19.7057 12.5035 19.7057 13.0885ZM19.7057 21.9115C19.7057 22.4965 19.9381 23.0575 20.3518 23.4711C20.7654 23.8848 21.3265 24.1172 21.9115 24.1172C22.4965 24.1172 23.0575 23.8848 23.4711 23.4711C23.8848 23.0575 24.1172 22.4965 24.1172 21.9115C24.1172 21.3265 23.8848 20.7654 23.4711 20.3518C23.0575 19.9381 22.4965 19.7057 21.9115 19.7057C21.3265 19.7057 20.7654 19.9381 20.3518 20.3518C19.9381 20.7654 19.7057 21.3265 19.7057 21.9115Z" '
'stroke="#7D7C7C" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>';
/// 글자색 묶음 — 어두운 글자(기본) / 밝은 글자(어두운 배경용)
class _Ink {
const _Ink({required this.strong, required this.body, required this.soft, required this.sep});
final Color strong; // 이름·값
final Color body; // 줄 기본색
final Color soft; // 라벨·품종
final Color sep; // 구분선
static const dark = _Ink(
strong: AppColors.ink,
body: AppColors.ink2,
soft: AppColors.muted,
sep: AppColors.line,
);
static const light = _Ink(
strong: Colors.white,
body: Color(0xE0FFFFFF), // .88
soft: Color(0xA3FFFFFF), // .64
sep: Color(0x4DFFFFFF), // .3
);
}
/// 펫 명함 (시안 ProfileCard variant="namecard") — 사진 + 이름·성별·품종·생일·체중·나이·등록번호
class NameCard extends StatelessWidget {
const NameCard({
super.key,
required this.pet,
required this.style,
this.today,
this.embedded = false,
this.onShare,
this.onEditPhoto,
});
final Pet pet;
final CardStyle style;
/// 나이 계산 기준일 (테스트용). 없으면 오늘
final DateTime? today;
/// 다른 카드 안에 들어가는 형태 — 그림자 없이 모서리 14
final bool embedded;
/// 공유 버튼 (없으면 숨김)
final VoidCallback? onShare;
/// 사진의 카메라 버튼 (명함 편집기에서만)
final VoidCallback? onEditPhoto;
@override
Widget build(BuildContext context) {
final radius = embedded ? _kEmbeddedRadius : _kRadius;
final ink = style.textLight ? _Ink.light : _Ink.dark;
final bgImage = style.nameBgImage;
return Semantics(
container: true,
label: '${pet.name} 펫 명함',
child: Container(
decoration: BoxDecoration(
color: bgImage != null ? Colors.white : (style.nameBg ?? Colors.white),
borderRadius: BorderRadius.circular(radius),
border: Border.all(color: AppColors.line),
boxShadow: embedded ? null : AppShadows.md,
),
clipBehavior: Clip.antiAlias,
child: Stack(
children: [
if (bgImage != null) Positioned.fill(child: CardBackgroundImage(path: bgImage)),
if (style.scrim) Positioned.fill(child: _Scrim(tone: style.scrimTone)),
Padding(
padding: const EdgeInsets.all(10),
child: IntrinsicHeight(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_Photo(pet: pet, onEdit: onEditPhoto),
const SizedBox(width: 14),
Expanded(child: _Body(pet: pet, ink: ink, today: today, onShare: onShare)),
],
),
),
),
],
),
),
);
}
}
/// 글자 뒤 막 — 왼쪽(사진 쪽)은 투명, 오른쪽(글자 쪽)으로 갈수록 진해진다
class _Scrim extends StatelessWidget {
const _Scrim({required this.tone});
final ScrimTone tone;
@override
Widget build(BuildContext context) {
final base = tone == ScrimTone.dark ? const Color(0xFF1A1512) : Colors.white;
final peak = tone == ScrimTone.dark ? .10 : .50;
return DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
stops: const [0, .28, .42, .58, 1],
colors: [
base.withValues(alpha: 0),
base.withValues(alpha: 0),
base.withValues(alpha: peak * .53),
base.withValues(alpha: peak),
base.withValues(alpha: peak),
],
),
),
);
}
}
class _Photo extends StatelessWidget {
const _Photo({required this.pet, required this.onEdit});
final Pet pet;
final VoidCallback? onEdit;
@override
Widget build(BuildContext context) {
final photo = pet.photoPath;
return ConstrainedBox(
constraints: const BoxConstraints(minWidth: _kPhotoWidth, maxWidth: _kPhotoWidth, minHeight: _kPhotoMinHeight),
child: Stack(
fit: StackFit.expand,
children: [
Container(
decoration: BoxDecoration(
color: photo == null ? _kEmptyPhotoFill : null,
borderRadius: BorderRadius.circular(_kPhotoRadius),
// 시안: 편집기에서는 기본 아이콘을 "채워진 사진"으로 다뤄 회색 테두리를 그리지 않는다
border: Border.all(
color: photo == null && onEdit == null ? _kEmptyPhotoBorder : Colors.transparent,
width: 1.5,
),
),
clipBehavior: Clip.antiAlias,
child: photo == null
? SvgPicture.asset(AppAssets.petDefault, fit: BoxFit.cover)
: PetPhotoImage(path: photo),
),
if (onEdit != null)
Positioned(
right: 5,
bottom: 5,
child: Semantics(
button: true,
label: '사진 변경',
child: GestureDetector(
onTap: onEdit,
child: Container(
width: 27,
height: 27,
decoration: BoxDecoration(
color: Colors.white,
shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: 2),
boxShadow: const [BoxShadow(color: Color(0x59000000), offset: Offset(0, 1), blurRadius: 5)],
),
alignment: Alignment.center,
child: const RupIcon(RupIconName.camera, size: 14, stroke: 2, color: AppColors.primary),
),
),
),
),
],
),
);
}
}
class _Body extends StatelessWidget {
const _Body({required this.pet, required this.ink, required this.today, required this.onShare});
final Pet pet;
final _Ink ink;
final DateTime? today;
final VoidCallback? onShare;
@override
Widget build(BuildContext context) {
final now = today ?? DateTime.now();
final years = petAgeYears(pet.birthDate, now);
final human = humanAgeOf(years);
final gender = kGenderColors[pet.gender]!;
final small = Theme.of(context).textTheme.bodySmall!.copyWith(fontSize: 12.5, color: ink.body, height: 1.3);
final label = small.copyWith(color: ink.soft);
final value = small.copyWith(color: ink.strong, fontWeight: FontWeight.w800, letterSpacing: .2);
return Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
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,
),
),
),
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),
),
),
],
),
const SizedBox(height: 6),
Text(
pet.breed.isEmpty ? pet.species : pet.breed,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: small.copyWith(fontSize: 13, color: ink.soft),
),
const SizedBox(height: 10),
_kv('생일', shortBirth(pet.birthDate), label, value),
const SizedBox(height: 6),
_kv('체중', pet.weightLabel, label, value),
const SizedBox(height: 10),
Text.rich(
TextSpan(children: [
TextSpan(text: '나이 ', style: label),
// 1살 미만은 개월로 (시안은 "N개월" 의 숫자를 "N세" 로 잘못 보여 준다)
TextSpan(text: years > 0 ? '$years세' : '${_months(pet.birthDate, now)}개월', style: value),
TextSpan(text: ' | ', style: small.copyWith(color: ink.sep)),
TextSpan(text: '사람나이 ', style: label),
TextSpan(text: human == null ? '–' : '$human세', style: value),
]),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 10),
Text.rich(
TextSpan(children: [
TextSpan(text: '등록번호 ', style: label),
TextSpan(
text: pet.microchip ?? kUnregistered,
style: value.copyWith(fontWeight: FontWeight.w700, letterSpacing: .3),
),
]),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
);
}
static int _months(DateTime birth, DateTime today) {
var m = (today.year - birth.year) * 12 + (today.month - birth.month);
if (today.day < birth.day) m--;
return m < 0 ? 0 : m;
}
Widget _kv(String k, String v, TextStyle label, TextStyle value) {
return Text.rich(
TextSpan(children: [
TextSpan(text: '$k ', style: label),
TextSpan(text: v, style: value),
]),
);
}
}
@@ -0,0 +1,331 @@
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/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';
import 'package:tma_app/shared/widgets/icons/rup_icon.dart';
import 'package:tma_app/shared/widgets/rup_logo.dart';
/// 큰 카드 그림자 — 시안 --shadow-lg
const List<BoxShadow> _kShadowLg = [
BoxShadow(color: Color(0x29462D14), offset: Offset(0, 18), blurRadius: 40),
BoxShadow(color: Color(0x14462D14), offset: Offset(0, 4), blurRadius: 12),
];
const double _kCardRadius = 26;
const double _kHealthLabelWidth = 64;
/// 건강 정보 태그 색 — 시안 HealthRow tones
enum _Tone {
red(Color(0x1AFF6B6B), Color(0xFFD9524E)),
amber(Color(0x24FFAA4D), Color(0xFFC77A1F)),
purple(Color(0x1AB07CF0), Color(0xFF8E5BD0));
const _Tone(this.bg, this.fg);
final Color bg;
final Color fg;
}
/// 상세 명함 (시안 ProfileCard variant="id" omitBasics withCard) —
/// 색 헤더 + 명함 + 종·혈액형·중성화 + 건강 정보 + 보호자 메모
class PetIdCard extends StatelessWidget {
const PetIdCard({
super.key,
required this.pet,
required this.style,
this.isRepresentative = false,
this.onToggleRepresentative,
this.onShare,
});
final Pet pet;
final CardStyle style;
final bool isRepresentative;
/// 헤더의 왕관 (없으면 숨김)
final VoidCallback? onToggleRepresentative;
final VoidCallback? onShare;
@override
Widget build(BuildContext context) {
return Container(
decoration: BoxDecoration(
color: AppColors.surface,
borderRadius: BorderRadius.circular(_kCardRadius),
boxShadow: _kShadowLg,
),
clipBehavior: Clip.antiAlias,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_Header(
style: style,
isRepresentative: isRepresentative,
onToggle: onToggleRepresentative,
),
Padding(
padding: const EdgeInsets.fromLTRB(AppSpacing.lg, AppSpacing.lg, AppSpacing.lg, 6),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
NameCard(pet: pet, style: style, embedded: true, onShare: onShare),
const SizedBox(height: AppSpacing.lg),
_Facts(pet: pet),
const SizedBox(height: AppSpacing.lg),
Row(
children: [
const RupIcon(RupIconName.shield, size: 16, stroke: 2, color: AppColors.accent),
const SizedBox(width: AppSpacing.sm),
Text(
'건강 정보',
style: Theme.of(context).textTheme.bodyMedium!.copyWith(
fontSize: 13,
fontWeight: FontWeight.w800,
),
),
],
),
const SizedBox(height: AppSpacing.xs),
_HealthRow(label: '알레르기', items: pet.allergies, tone: _Tone.red),
_HealthRow(label: '보유질환', items: pet.conditions, tone: _Tone.amber),
_HealthRow(label: '염려질환', items: pet.watchConditions, tone: _Tone.purple),
_HealthRow(
label: '담당병원',
items: [if (pet.vet != null) pet.vet!],
tone: _Tone.purple,
),
if (pet.memo != null) _Memo(text: pet.memo!),
],
),
),
const SizedBox(height: 10),
Container(
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.xl, vertical: AppSpacing.md),
decoration: const BoxDecoration(
color: AppColors.surface2,
border: Border(top: BorderSide(color: AppColors.line2)),
),
alignment: Alignment.centerRight,
child: Text(
'RUP으로 만든 펫 명함',
style: Theme.of(context).textTheme.bodySmall!.copyWith(fontSize: 11, color: AppColors.muted),
),
),
],
),
);
}
}
class _Header extends StatelessWidget {
const _Header({required this.style, required this.isRepresentative, required this.onToggle});
final CardStyle style;
final bool isRepresentative;
final VoidCallback? onToggle;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.xl, vertical: 18),
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [style.tint, style.tint2],
),
),
child: Row(
children: [
const RupLogo(height: 24, white: true),
const Spacer(),
if (onToggle != null) RepresentativeCrown(active: isRepresentative, onTap: onToggle!),
],
),
);
}
}
/// 대표 명함 왕관 버튼 — 대표면 흰 원 + 금색 왕관, 아니면 반투명 원 + 흰 선 왕관
class RepresentativeCrown extends StatelessWidget {
const RepresentativeCrown({super.key, required this.active, required this.onTap});
final bool active;
final VoidCallback onTap;
static String _crown({required String fill, required String stroke}) =>
'<svg viewBox="0 0 24 24" fill="none">'
'<path d="M3.2 7.4 7.4 10.6 12 4.8l4.6 5.8 4.2-3.2-1.5 9.2a1.1 1.1 0 0 1-1.08.9H5.78a1.1 1.1 0 0 1-1.08-.9L3.2 7.4Z" '
'fill="$fill" stroke="$stroke" stroke-width="1.7" stroke-linejoin="round" stroke-linecap="round"/>'
'<path d="M5.5 20.2h13" stroke="$stroke" stroke-width="1.7" stroke-linecap="round"/></svg>';
@override
Widget build(BuildContext context) {
return Semantics(
button: true,
selected: active,
label: active ? '대표 명함' : '대표 명함으로 설정',
child: GestureDetector(
onTap: onTap,
child: Container(
width: 40,
height: 40,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: active ? Colors.white : Colors.white.withValues(alpha: .22),
border: active ? null : Border.all(color: Colors.white.withValues(alpha: .75), width: 1.5),
boxShadow: active
? const [BoxShadow(color: Color(0x40783C14), offset: Offset(0, 2), blurRadius: 6)]
: null,
),
alignment: Alignment.center,
child: SvgPicture.string(
active ? _crown(fill: '#F4B73E', stroke: '#E0A52B') : _crown(fill: 'none', stroke: '#FFFFFF'),
width: 22,
height: 22,
),
),
),
);
}
}
/// 종 | 혈액형 | 중성화
class _Facts extends StatelessWidget {
const _Facts({required this.pet});
final Pet pet;
@override
Widget build(BuildContext context) {
Widget field(String k, String v, int flex) => Expanded(
flex: flex,
child: _Field(k: k, v: v),
);
const divider = SizedBox(
width: 1,
height: 16,
child: ColoredBox(color: AppColors.line),
);
return Row(
children: [
field('종', pet.breed.isEmpty ? pet.species : pet.breed, 19),
divider,
const SizedBox(width: AppSpacing.sm),
field('혈액형', pet.bloodType ?? kUnregistered, 10),
divider,
const SizedBox(width: AppSpacing.sm),
field('중성화', pet.neutered ? '완료' : '안함', 10),
],
);
}
}
class _Field extends StatelessWidget {
const _Field({required this.k, required this.v});
final String k;
final String v;
@override
Widget build(BuildContext context) {
final base = Theme.of(context).textTheme.bodySmall!;
return Padding(
padding: const EdgeInsets.only(right: AppSpacing.sm),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(k, style: base.copyWith(fontSize: 11, fontWeight: FontWeight.w600, color: AppColors.muted)),
const SizedBox(height: 3),
Text(
v,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: base.copyWith(fontSize: 14, fontWeight: FontWeight.w600, color: AppColors.ink),
),
],
),
);
}
}
class _HealthRow extends StatelessWidget {
const _HealthRow({required this.label, required this.items, required this.tone});
final String label;
final List<String> items;
final _Tone tone;
@override
Widget build(BuildContext context) {
final base = Theme.of(context).textTheme.bodySmall!;
return Container(
padding: const EdgeInsets.symmetric(vertical: 9),
decoration: const BoxDecoration(border: Border(top: BorderSide(color: AppColors.line2))),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: _kHealthLabelWidth,
child: Padding(
padding: const EdgeInsets.only(top: 3),
child: Text(label, style: base.copyWith(fontWeight: FontWeight.w700, color: AppColors.ink2)),
),
),
const SizedBox(width: 10),
Expanded(
child: items.isEmpty
? Padding(
padding: const EdgeInsets.only(top: 3),
child: Text('없음 / 해당 없음', style: base.copyWith(fontSize: 13, color: AppColors.muted)),
)
: Wrap(
spacing: 5,
runSpacing: 5,
children: [
for (final it in items)
Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: tone.bg,
borderRadius: BorderRadius.circular(10),
),
child: Text(it, style: base.copyWith(fontWeight: FontWeight.w600, color: tone.fg)),
),
],
),
),
],
),
);
}
}
class _Memo extends StatelessWidget {
const _Memo({required this.text});
final String text;
@override
Widget build(BuildContext context) {
final base = Theme.of(context).textTheme.bodySmall!;
return Container(
margin: const EdgeInsets.only(top: AppSpacing.md),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: AppSpacing.md),
decoration: BoxDecoration(
color: AppColors.primary50,
borderRadius: BorderRadius.circular(14),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('보호자 메모', style: base.copyWith(fontSize: 11, fontWeight: FontWeight.w700, color: AppColors.primary600)),
const SizedBox(height: AppSpacing.xs),
Text(text, style: base.copyWith(fontSize: 13, height: 1.5, color: AppColors.ink2)),
],
),
);
}
}
@@ -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/core/theme/app_typography.dart';
import 'package:tma_app/features/pet/domain/pet.dart';
import 'package:tma_app/features/pet/domain/pet_age.dart';
import 'package:tma_app/features/pet/presentation/widgets/pet_avatar.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/rup_sheet.dart';
/// 어느 아이의 명함을 만들지 고르는 시트 — 고른 아이의 id 를 돌려준다
Future<String?> showPickPetSheet(BuildContext context, {required List<Pet> pets}) {
return showRupSheet<String>(
context,
builder: (context) {
final textTheme = Theme.of(context).textTheme;
final today = DateTime.now();
return SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Text(
'펫 명함 만들기',
textAlign: TextAlign.center,
style: TextStyle(fontFamily: AppFonts.display, fontSize: 18, fontWeight: FontWeight.w600, color: AppColors.ink),
),
const SizedBox(height: 12),
Text(
'어떤 반려동물의 카드를 만들까요?\n등록된 정보로 카드가 자동 채워져요.',
textAlign: TextAlign.center,
style: textTheme.bodySmall!.copyWith(fontSize: 12.5, color: AppColors.muted, height: 1.5),
),
const SizedBox(height: 18),
for (final (i, p) in pets.indexed) ...[
if (i > 0) const SizedBox(height: 10),
Semantics(
button: true,
child: RupCard(
onTap: () => Navigator.of(context).pop(p.id),
padding: const EdgeInsets.all(14),
child: Row(
children: [
PetAvatar(pet: p, size: 44),
const SizedBox(width: 13),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(p.name, style: textTheme.titleSmall),
Text(
[p.species, if (p.breed.isNotEmpty) p.breed, petAgeLabel(p.birthDate, today)].join(' · '),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: textTheme.bodySmall!.copyWith(fontSize: 12.5, color: AppColors.muted),
),
],
),
),
const RupIcon(RupIconName.chevR, size: 18, color: AppColors.muted),
],
),
),
),
],
const SizedBox(height: AppSpacing.xs),
],
),
);
},
);
}
@@ -0,0 +1,333 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:qr_flutter/qr_flutter.dart';
import 'package:share_plus/share_plus.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_shadows.dart';
import 'package:tma_app/features/pet_card/domain/pet_card_facts.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_button.dart';
import 'package:tma_app/shared/widgets/rup_sheet.dart';
import 'package:tma_app/shared/widgets/rup_toast.dart';
/// QR 에 담는 주소 — 앱 딥링크. 서버가 명함 조회 API 를 열면 같은 코드로 찾는다
String petCardDeepLink(String code) => 'rup://pet-card/$code';
const double _kQrSize = 220;
const double _kQrBadge = 54;
/// 명함 공유 시트 — 명함 코드 + 이미지로 저장 · 코드 복사 · QR 공유.
/// [capture] 는 지금 화면에 보이는 명함을 PNG 로 떠 낸다.
Future<void> showPetCardShareSheet(
BuildContext context, {
required String petName,
required String codeSeed,
required InlineSpan who,
required Future<Uint8List?> Function() capture,
}) {
return showRupSheet<void>(
context,
title: '$petName 펫 명함 공유',
builder: (context) => _ShareBody(
petName: petName,
code: petCardCode(codeSeed),
who: who,
capture: capture,
),
);
}
Future<void> _sharePng(Uint8List bytes, String fileName) async {
await SharePlus.instance.share(
ShareParams(files: [XFile.fromData(bytes, mimeType: 'image/png', name: fileName)]),
);
}
class _ShareBody extends ConsumerWidget {
const _ShareBody({required this.petName, required this.code, required this.who, required this.capture});
final String petName;
final String code;
final InlineSpan who;
final Future<Uint8List?> Function() capture;
@override
Widget build(BuildContext context, WidgetRef ref) {
final textTheme = Theme.of(context).textTheme;
final toast = ref.read(toastProvider.notifier);
Future<void> saveImage() async {
final bytes = await capture();
if (context.mounted) Navigator.of(context).pop();
if (bytes != null) await _sharePng(bytes, 'rup_${petName}_명함.png');
}
Future<void> copyCode() async {
await Clipboard.setData(ClipboardData(text: code));
if (context.mounted) Navigator.of(context).pop();
toast.show('명함 코드를 복사했어요', icon: RupIconName.link);
}
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text.rich(
who,
textAlign: TextAlign.center,
style: textTheme.bodySmall!.copyWith(fontSize: 13, fontWeight: FontWeight.w600, color: AppColors.ink2),
),
const SizedBox(height: 20),
Container(
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.lg, vertical: 13),
decoration: BoxDecoration(
color: AppColors.surface2,
borderRadius: BorderRadius.circular(14),
border: Border.all(color: AppColors.line),
),
child: Column(
children: [
Text(
'명함 코드',
style: textTheme.bodySmall!.copyWith(
fontSize: 11.5,
fontWeight: FontWeight.w700,
color: AppColors.muted,
letterSpacing: .23,
),
),
const SizedBox(height: 3),
Text(
code,
style: textTheme.titleSmall!.copyWith(fontSize: 19, fontWeight: FontWeight.w800, letterSpacing: 1.5),
),
],
),
),
const SizedBox(height: 14),
Row(
children: [
Expanded(
child: _ShareTile(
icon: RupIconName.download,
label: '이미지로\n저장',
desc: '앨범에 저장',
tint: const Color(0xFF2FA877),
onTap: () => unawaited(saveImage()),
),
),
const SizedBox(width: 11),
Expanded(
child: _ShareTile(
icon: RupIconName.link,
label: '명함 코드\n복사',
desc: '친구에게 전송',
tint: const Color(0xFF3D8FD6),
onTap: () => unawaited(copyCode()),
),
),
const SizedBox(width: 11),
Expanded(
child: _ShareTile(
icon: RupIconName.qrcode,
label: 'QR 코드\n공유',
desc: '스캔 한 번',
tint: const Color(0xFF9B6BE8),
onTap: () => unawaited(_showQr(context, petName: petName, code: code)),
),
),
],
),
],
);
}
}
class _ShareTile extends StatelessWidget {
const _ShareTile({
required this.icon,
required this.label,
required this.desc,
required this.tint,
required this.onTap,
});
final RupIconName icon;
final String label;
final String desc;
final Color tint;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final textTheme = Theme.of(context).textTheme;
return Semantics(
button: true,
label: label.replaceAll('\n', ' '),
excludeSemantics: true,
child: GestureDetector(
onTap: onTap,
child: Container(
padding: const EdgeInsets.fromLTRB(5, 20, 5, 16),
decoration: BoxDecoration(
color: Color.alphaBlend(tint.withValues(alpha: .08), AppColors.surface),
borderRadius: BorderRadius.circular(20),
border: Border.all(color: tint.withValues(alpha: .22)),
),
child: Column(
children: [
Container(
width: 54,
height: 54,
decoration: BoxDecoration(
color: tint,
borderRadius: BorderRadius.circular(17),
boxShadow: [BoxShadow(color: tint.withValues(alpha: .3), offset: const Offset(0, 3), blurRadius: 8)],
),
alignment: Alignment.center,
child: icon == RupIconName.qrcode
? const RupGlyphIcon(RupGlyph.qr, size: 26, color: Colors.white)
: RupIcon(icon, size: 26, stroke: 2, color: Colors.white),
),
const SizedBox(height: 17),
Text(
label,
textAlign: TextAlign.center,
style: textTheme.bodyMedium!.copyWith(fontWeight: FontWeight.w800, height: 1.22),
),
const SizedBox(height: 8),
Text(
desc,
maxLines: 1,
style: textTheme.bodySmall!.copyWith(fontSize: 10.5, color: AppColors.muted, height: 1.3),
),
],
),
),
),
);
}
}
/// 펫 명함 QR — 가운데 명함 표시가 있는 QR + 이미지 저장
Future<void> _showQr(BuildContext context, {required String petName, required String code}) {
final capture = CaptureController();
return showDialog<void>(
context: context,
useRootNavigator: true,
barrierColor: AppColors.scrim,
builder: (context) {
final textTheme = Theme.of(context).textTheme;
return Dialog(
backgroundColor: Colors.transparent,
elevation: 0,
insetPadding: const EdgeInsets.all(AppSpacing.xl),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 320),
child: Container(
padding: const EdgeInsets.all(28),
decoration: BoxDecoration(
color: AppColors.surface,
borderRadius: BorderRadius.circular(AppRadius.card),
boxShadow: AppShadows.sm,
),
child: Stack(
clipBehavior: Clip.none,
children: [
Column(
mainAxisSize: MainAxisSize.min,
children: [
Text('펫 명함 QR', style: textTheme.titleSmall!.copyWith(fontSize: 17, fontWeight: FontWeight.w800)),
const SizedBox(height: AppSpacing.xs),
Text.rich(
TextSpan(children: [
const TextSpan(text: '상대방이 카메라로 스캔하면\n'),
TextSpan(
text: petName,
style: const TextStyle(fontWeight: FontWeight.w700, color: AppColors.ink),
),
const TextSpan(text: '의 펫 명함을 바로 볼 수 있어요.'),
]),
textAlign: TextAlign.center,
style: textTheme.bodySmall!.copyWith(fontSize: 13, height: 1.55, color: AppColors.ink2),
),
const SizedBox(height: 18),
CaptureBoundary(
controller: capture,
child: Container(
padding: const EdgeInsets.all(18),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(22),
border: Border.all(color: AppColors.line),
boxShadow: AppShadows.sm,
),
child: Stack(
alignment: Alignment.center,
children: [
QrImageView(
data: petCardDeepLink(code),
size: _kQrSize,
padding: EdgeInsets.zero,
errorCorrectionLevel: QrErrorCorrectLevel.H,
semanticsLabel: '$petName 펫 명함 QR 코드',
),
Container(
width: _kQrBadge,
height: _kQrBadge,
decoration: BoxDecoration(
color: AppColors.primary,
borderRadius: BorderRadius.circular(17),
boxShadow: const [BoxShadow(color: Colors.white, spreadRadius: 5)],
),
alignment: Alignment.center,
child: const RupGlyphIcon(RupGlyph.petCard, size: 28, color: Colors.white),
),
],
),
),
),
const SizedBox(height: 28),
RupButton(
label: 'QR 이미지 저장',
large: true,
leading: const RupIcon(RupIconName.download, size: 18, stroke: 2.1, color: Colors.white),
onPressed: () async {
final bytes = await capture.capturePng();
if (context.mounted) Navigator.of(context).pop();
if (bytes != null) await _sharePng(bytes, 'rup_${petName}_QR.png');
},
),
],
),
Positioned(
top: -16,
right: -16,
child: Semantics(
button: true,
label: '닫기',
child: GestureDetector(
onTap: () => Navigator.of(context).pop(),
child: Container(
width: 32,
height: 32,
decoration: const BoxDecoration(color: AppColors.surface2, shape: BoxShape.circle),
alignment: Alignment.center,
child: const RupIcon(RupIconName.x, size: 18, stroke: 2.4, color: AppColors.ink2),
),
),
),
),
],
),
),
),
);
},
);
}