diff --git a/flutter/assets/images/brand/rup_logo.png b/flutter/assets/images/brand/rup_logo.png new file mode 100644 index 0000000..41ce6bb Binary files /dev/null and b/flutter/assets/images/brand/rup_logo.png differ diff --git a/flutter/assets/images/brand/rup_logo_white.png b/flutter/assets/images/brand/rup_logo_white.png new file mode 100644 index 0000000..21c57e8 Binary files /dev/null and b/flutter/assets/images/brand/rup_logo_white.png differ diff --git a/flutter/assets/svg/card_bg/bg_1.svg b/flutter/assets/svg/card_bg/bg_1.svg new file mode 100644 index 0000000..c80fabd --- /dev/null +++ b/flutter/assets/svg/card_bg/bg_1.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/flutter/assets/svg/card_bg/bg_2.svg b/flutter/assets/svg/card_bg/bg_2.svg new file mode 100644 index 0000000..ce35491 --- /dev/null +++ b/flutter/assets/svg/card_bg/bg_2.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/flutter/assets/svg/card_bg/bg_3.svg b/flutter/assets/svg/card_bg/bg_3.svg new file mode 100644 index 0000000..54b7492 --- /dev/null +++ b/flutter/assets/svg/card_bg/bg_3.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/flutter/assets/svg/card_bg/bg_4.svg b/flutter/assets/svg/card_bg/bg_4.svg new file mode 100644 index 0000000..a10b3fb --- /dev/null +++ b/flutter/assets/svg/card_bg/bg_4.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/flutter/lib/core/constants/app_assets.dart b/flutter/lib/core/constants/app_assets.dart index 2e2e1e3..c73a332 100644 --- a/flutter/lib/core/constants/app_assets.dart +++ b/flutter/lib/core/constants/app_assets.dart @@ -40,6 +40,18 @@ abstract final class AppAssets { static const String addPhoto = 'assets/svg/common/add_photo.svg'; static const String addCard = 'assets/svg/common/add_card.svg'; + // 브랜드 로고 (명함 헤더 등) + static const String logo = 'assets/images/brand/rup_logo.png'; + static const String logoWhite = 'assets/images/brand/rup_logo_white.png'; + + /// 펫 명함 기본 배경 이미지 4종 + static const List cardBackgrounds = [ + 'assets/svg/card_bg/bg_1.svg', + 'assets/svg/card_bg/bg_2.svg', + 'assets/svg/card_bg/bg_3.svg', + 'assets/svg/card_bg/bg_4.svg', + ]; + /// 종 실루엣 아이콘 — [speciesIconKey] 는 dog·cat·… (없으면 other) static String speciesIcon(String speciesIconKey) => 'assets/svg/species/$speciesIconKey.svg'; diff --git a/flutter/lib/core/router/app_router.dart b/flutter/lib/core/router/app_router.dart index bdffe41..35a513b 100644 --- a/flutter/lib/core/router/app_router.dart +++ b/flutter/lib/core/router/app_router.dart @@ -9,6 +9,10 @@ import 'package:tma_app/features/home/presentation/pages/home_page.dart'; import 'package:tma_app/features/intro/presentation/pages/intro_page.dart'; import 'package:tma_app/features/onboarding/presentation/pages/onboarding_page.dart'; import 'package:tma_app/features/pet/presentation/pages/pet_register_page.dart'; +import 'package:tma_app/features/pet_card/presentation/pages/pet_card_detail_page.dart'; +import 'package:tma_app/features/pet_card/presentation/pages/pet_card_editor_page.dart'; +import 'package:tma_app/features/pet_card/presentation/pages/pet_card_manage_page.dart'; +import 'package:tma_app/features/pet_card/presentation/pages/pet_cards_page.dart'; import 'package:tma_app/features/shell/presentation/pages/main_shell.dart'; import 'package:tma_app/features/shell/presentation/pages/tab_placeholder_page.dart'; import 'package:tma_app/features/splash/presentation/pages/splash_page.dart'; @@ -70,6 +74,30 @@ final appRouterProvider = Provider((ref) { builder: (context, state) => PetRegisterPage(petId: state.pathParameters['petId']), ), + // 펫 명함 — 경로가 겹치므로 고정 경로(manage·new)를 :cardId 보다 먼저 둔다 + GoRoute( + path: AppRoutes.petCardManage, + parentNavigatorKey: rootKey, + builder: (context, state) => const PetCardManagePage(), + ), + GoRoute( + path: AppRoutes.petCardCreatePattern, + parentNavigatorKey: rootKey, + builder: (context, state) => + PetCardEditorPage.create(petId: state.pathParameters['petId']!), + ), + GoRoute( + path: AppRoutes.petCardEditPattern, + parentNavigatorKey: rootKey, + builder: (context, state) => + PetCardEditorPage.edit(cardId: state.pathParameters['cardId']!), + ), + GoRoute( + path: AppRoutes.petCardDetailPattern, + parentNavigatorKey: rootKey, + builder: (context, state) => + PetCardDetailPage(cardId: state.pathParameters['cardId']!), + ), StatefulShellRoute.indexedStack( pageBuilder: (context, state, navigationShell) => _fadePage(state, MainShell(navigationShell: navigationShell)), @@ -95,8 +123,7 @@ final appRouterProvider = Provider((ref) { routes: [ GoRoute( path: AppRoutes.petCards, - builder: (context, state) => - const TabPlaceholderPage(title: '펫 명함'), + builder: (context, state) => const PetCardsPage(), ), ], ), diff --git a/flutter/lib/core/router/app_routes.dart b/flutter/lib/core/router/app_routes.dart index de7ef6f..d428df1 100644 --- a/flutter/lib/core/router/app_routes.dart +++ b/flutter/lib/core/router/app_routes.dart @@ -18,6 +18,14 @@ abstract final class AppRoutes { static const String petEditPattern = '/pets/:petId/edit'; static String petEdit(String petId) => '/pets/$petId/edit'; + static const String petCardManage = '/pet-cards/manage'; + static const String petCardCreatePattern = '/pet-cards/new/:petId'; + static const String petCardDetailPattern = '/pet-cards/:cardId'; + static const String petCardEditPattern = '/pet-cards/:cardId/edit'; + static String petCardCreate(String petId) => '/pet-cards/new/$petId'; + static String petCardDetail(String cardId) => '/pet-cards/$cardId'; + static String petCardEdit(String cardId) => '/pet-cards/$cardId/edit'; + /// 로그인 전에만 머무를 수 있는 화면 static const Set _signedOutOnly = {intro, login}; diff --git a/flutter/lib/core/theme/app_typography.dart b/flutter/lib/core/theme/app_typography.dart index e20726a..255314e 100644 --- a/flutter/lib/core/theme/app_typography.dart +++ b/flutter/lib/core/theme/app_typography.dart @@ -22,6 +22,13 @@ double _tracking(double fontSize) => fontSize * -0.01; /// 앱 TextTheme — 크기는 시안 px(=논리 픽셀) 값을 그대로 쓴다. /// 화면에서는 `Theme.of(context).textTheme.xxx` 로 꺼내 쓰고, 굵기·색만 copyWith 로 조정한다. abstract final class AppTypography { + /// CSS 'line-height: normal' 에 해당하는 줄 높이 — 시안에서 줄 높이를 따로 주지 않은 글자. + /// 지정하지 않으면 본문 기본값(bodyMedium 1.6)을 물려받아 시안보다 줄이 커진다. + static const double normalHeight = 1.25; + + /// 제목 서체(ChungbukBold)의 normal 줄 높이 — 글자 크기와 거의 같다 + static const double displayNormalHeight = 1.1; + static TextStyle _ui(double size, FontWeight weight, {double? height}) => TextStyle( fontFamily: AppFonts.ui, diff --git a/flutter/lib/core/utils/color_utils.dart b/flutter/lib/core/utils/color_utils.dart new file mode 100644 index 0000000..ab9fe4f --- /dev/null +++ b/flutter/lib/core/utils/color_utils.dart @@ -0,0 +1,25 @@ +import 'dart:ui' show Color; + +/// "#RRGGBB" → Color. 형식이 틀리면 null +Color? colorFromHex(String? hex) { + if (hex == null) return null; + final m = RegExp(r'^#?([0-9a-fA-F]{6})$').firstMatch(hex.trim()); + if (m == null) return null; + return Color(0xFF000000 | int.parse(m.group(1)!, radix: 16)); +} + +/// Color → "#RRGGBB" (대문자) +String hexOf(Color c) { + final rgb = c.toARGB32() & 0xFFFFFF; + return '#${rgb.toRadixString(16).padLeft(6, '0').toUpperCase()}'; +} + +/// [amount](0~1) 만큼 흰색과 섞어 밝게 — 시안 lightenHex (직접 고른 헤더색의 두 번째 색) +Color lighten(Color c, double amount) { + int mix(double channel) { + final v = (channel * 255).round(); + return (v + (255 - v) * amount).round(); + } + + return Color.fromARGB(255, mix(c.r), mix(c.g), mix(c.b)); +} diff --git a/flutter/lib/features/home/presentation/pages/home_page.dart b/flutter/lib/features/home/presentation/pages/home_page.dart index 2938bb1..676c365 100644 --- a/flutter/lib/features/home/presentation/pages/home_page.dart +++ b/flutter/lib/features/home/presentation/pages/home_page.dart @@ -51,13 +51,7 @@ class HomePage extends ConsumerWidget { ), ), ), - padded( - HomePetsSection( - onRegister: () => context.push(AppRoutes.petRegister), - // 펫 명함 만들기는 명함 탭에서 이어서 만든다 - onCreateCard: (pet) => context.go(AppRoutes.petCards), - ), - ), + padded(HomePetsSection(onRegister: () => context.push(AppRoutes.petRegister))), const DividerBand(), padded( HomeScheduleSection( diff --git a/flutter/lib/features/home/presentation/widgets/home_pets_section.dart b/flutter/lib/features/home/presentation/widgets/home_pets_section.dart index 040e25a..df49f07 100644 --- a/flutter/lib/features/home/presentation/widgets/home_pets_section.dart +++ b/flutter/lib/features/home/presentation/widgets/home_pets_section.dart @@ -1,54 +1,81 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; import 'package:tma_app/core/constants/app_colors.dart'; import 'package:tma_app/core/constants/app_sizes.dart'; -import 'package:tma_app/features/pet/domain/pet.dart'; +import 'package:tma_app/core/router/app_routes.dart'; import 'package:tma_app/features/pet/presentation/providers/pet_providers.dart'; -import 'package:tma_app/features/pet/presentation/widgets/pet_avatar.dart'; +import 'package:tma_app/features/pet/presentation/widgets/pet_picker.dart'; +import 'package:tma_app/features/pet_card/presentation/pages/pet_cards_page.dart'; +import 'package:tma_app/features/pet_card/presentation/providers/pet_card_providers.dart'; +import 'package:tma_app/features/pet_card/presentation/widgets/name_card.dart'; import 'package:tma_app/features/pet_card/presentation/widgets/name_card_empty.dart'; +import 'package:tma_app/shared/widgets/capture_boundary.dart'; import 'package:tma_app/shared/widgets/icons/rup_icon.dart'; import 'package:tma_app/shared/widgets/rup_card.dart'; -/// 우리 아이 고르기 — 원형 사진 치수 (시안 .rup-petpick__ring 58 / 아바타 46) -const double _kRingSize = 58; -const double _kAvatarSize = 46; -const double _kRingBorder = 2.5; +/// 불러오는 동안 자리 높이 (아이 목록 줄 정도) +const double _kLoadingHeight = 100; -/// 홈 "우리 아이" — 아이가 없으면 등록 안내, 있으면 아이 고르기 + 대표 명함 자리 -class HomePetsSection extends ConsumerWidget { - const HomePetsSection({super.key, required this.onRegister, required this.onCreateCard}); +/// 홈 "우리 아이" — 아이가 없으면 등록 안내, 있으면 아이 고르기 + 대표 명함(없으면 만들기 안내) +class HomePetsSection extends ConsumerStatefulWidget { + const HomePetsSection({super.key, required this.onRegister}); final VoidCallback onRegister; - final ValueChanged onCreateCard; @override - Widget build(BuildContext context, WidgetRef ref) { + ConsumerState createState() => _HomePetsSectionState(); +} + +class _HomePetsSectionState extends ConsumerState { + final _capture = CaptureController(); + + @override + Widget build(BuildContext context) { final pets = ref.watch(petListProvider).value; // 불러오는 동안은 자리만 잡는다 — "없어요" 가 스치지 않게 - if (pets == null) return const SizedBox(height: _kRingSize + 40); + if (pets == null) return const SizedBox(height: _kLoadingHeight); if (pets.isEmpty) { // 시안: 카드 위 26, 아래는 비어 있는 아이 목록 줄의 여백(4+8)이 남는다 return Padding( padding: const EdgeInsets.only(top: 22, bottom: AppSpacing.md), - child: _RegisterPetCard(onTap: onRegister), + child: _RegisterPetCard(onTap: widget.onRegister), ); } final selected = ref.watch(selectedPetProvider); + final card = selected == null ? null : ref.watch(representativeCardProvider(selected.id)); return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ // 시안: 소제목 아래 12 (소제목 줄의 링크 터치 여백만큼 뺀다) const SizedBox(height: AppSpacing.sm), - _PetPicker( + PetPicker( pets: pets, selectedId: selected?.id, onSelect: (id) => ref.read(selectedPetIdProvider.notifier).select(id), ), if (selected != null) ...[ const SizedBox(height: 20), - // 펫 명함 기능 전까지는 모든 아이가 "명함 없음" 상태다 - NameCardEmpty(petName: selected.name, onCreate: () => onCreateCard(selected)), + if (card == null) + NameCardEmpty( + petName: selected.name, + onCreate: () => unawaited(context.push(AppRoutes.petCardCreate(selected.id))), + ) + else + GestureDetector( + onTap: () => unawaited(context.push(AppRoutes.petCardDetail(card.id))), + child: CaptureBoundary( + controller: _capture, + child: NameCard( + pet: selected, + style: card.style, + onShare: () => unawaited(sharePetCard(context, pet: selected, card: card, capture: _capture)), + ), + ), + ), ], ], ); @@ -102,66 +129,3 @@ class _RegisterPetCard extends StatelessWidget { ); } } - -/// 가로로 넘기는 아이 목록 — 고른 아이는 주황 테두리 -class _PetPicker extends StatelessWidget { - const _PetPicker({required this.pets, required this.selectedId, required this.onSelect}); - - final List pets; - final String? selectedId; - final ValueChanged onSelect; - - @override - Widget build(BuildContext context) { - final textTheme = Theme.of(context).textTheme; - return SingleChildScrollView( - scrollDirection: Axis.horizontal, - clipBehavior: Clip.none, - padding: const EdgeInsets.fromLTRB(2, AppSpacing.xs, 2, AppSpacing.sm), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - for (final (i, pet) in pets.indexed) ...[ - if (i > 0) const SizedBox(width: 14), - Semantics( - button: true, - selected: pet.id == selectedId, - label: pet.name, - excludeSemantics: true, - child: GestureDetector( - onTap: () => onSelect(pet.id), - child: Column( - children: [ - AnimatedContainer( - duration: AppDurations.fast, - width: _kRingSize, - height: _kRingSize, - decoration: BoxDecoration( - shape: BoxShape.circle, - border: Border.all( - color: pet.id == selectedId ? AppColors.primary : Colors.transparent, - width: _kRingBorder, - ), - ), - alignment: Alignment.center, - child: PetAvatar(pet: pet, size: _kAvatarSize), - ), - const SizedBox(height: 7), - Text( - pet.name, - style: textTheme.bodySmall!.copyWith( - fontSize: 12.5, - fontWeight: pet.id == selectedId ? FontWeight.w700 : FontWeight.w600, - color: pet.id == selectedId ? AppColors.primary600 : AppColors.muted, - ), - ), - ], - ), - ), - ), - ], - ], - ), - ); - } -} diff --git a/flutter/lib/features/pet/domain/pet.dart b/flutter/lib/features/pet/domain/pet.dart index fd8e732..9a461ff 100644 --- a/flutter/lib/features/pet/domain/pet.dart +++ b/flutter/lib/features/pet/domain/pet.dart @@ -31,6 +31,7 @@ class PetInput { required this.weight, required this.weightUnit, this.photoPath, + this.microchip, this.bloodType, this.vet, this.memo, @@ -55,6 +56,9 @@ class PetInput { /// 기기에 저장한 사진 경로 — 없으면 기본 아이콘 final String? photoPath; + /// 동물등록번호(마이크로칩) — 시안에서도 입력받지 않고 표시만 한다. 없으면 null + final String? microchip; + /// 값이 없으면 null ("미등록" 표기는 화면에서 한다) final String? bloodType; final String? vet; @@ -81,6 +85,7 @@ class Pet extends PetInput { required super.weight, required super.weightUnit, super.photoPath, + super.microchip, super.bloodType, super.vet, super.memo, @@ -100,6 +105,7 @@ class Pet extends PetInput { weight: input.weight, weightUnit: input.weightUnit, photoPath: input.photoPath, + microchip: input.microchip, bloodType: input.bloodType, vet: input.vet, memo: input.memo, @@ -110,6 +116,26 @@ class Pet extends PetInput { final String id; + /// 사진만 바꾼 입력값 — 명함 편집기의 카메라 버튼 + PetInput withPhoto(String path) => PetInput( + name: name, + species: species, + breed: breed, + gender: gender, + neutered: neutered, + birthDate: birthDate, + weight: weight, + weightUnit: weightUnit, + photoPath: path, + microchip: microchip, + bloodType: bloodType, + vet: vet, + memo: memo, + allergies: allergies, + conditions: conditions, + watchConditions: watchConditions, + ); + /// 3.2kg · 450g — 소수점 끝의 0 은 떼어 낸다 String get weightLabel { final text = weight == weight.roundToDouble() @@ -129,6 +155,7 @@ class Pet extends PetInput { 'weight': weight, 'weightUnit': weightUnit.name, 'photoPath': photoPath, + 'microchip': microchip, 'bloodType': bloodType, 'vet': vet, 'memo': memo, @@ -148,6 +175,7 @@ class Pet extends PetInput { weight: (json['weight']! as num).toDouble(), weightUnit: WeightUnit.values.byName(json['weightUnit']! as String), photoPath: json['photoPath'] as String?, + microchip: json['microchip'] as String?, bloodType: json['bloodType'] as String?, vet: json['vet'] as String?, memo: json['memo'] as String?, diff --git a/flutter/lib/features/pet/presentation/pages/pet_register_page.dart b/flutter/lib/features/pet/presentation/pages/pet_register_page.dart index 67fc030..24fc830 100644 --- a/flutter/lib/features/pet/presentation/pages/pet_register_page.dart +++ b/flutter/lib/features/pet/presentation/pages/pet_register_page.dart @@ -18,6 +18,7 @@ import 'package:tma_app/features/pet/presentation/widgets/allergen_selector.dart import 'package:tma_app/features/pet/presentation/widgets/pet_form_parts.dart'; import 'package:tma_app/features/pet/presentation/widgets/pet_photo_slot.dart'; import 'package:tma_app/features/pet/presentation/widgets/pet_pick_sheets.dart'; +import 'package:tma_app/features/pet_card/presentation/providers/pet_card_providers.dart'; import 'package:tma_app/shared/widgets/date_picker_sheet.dart'; import 'package:tma_app/shared/widgets/icons/rup_icon.dart'; import 'package:tma_app/shared/widgets/rup_button.dart'; @@ -44,18 +45,34 @@ List _splitList(String text) => String? _nullIfBlank(String text) => text.trim().isEmpty ? null : text.trim(); /// 반려동물 등록 / 수정 — [petId] 가 있으면 수정 모드 -class PetRegisterPage extends ConsumerStatefulWidget { +class PetRegisterPage extends ConsumerWidget { const PetRegisterPage({super.key, this.petId}); final String? petId; @override - ConsumerState createState() => _PetRegisterPageState(); + Widget build(BuildContext context, WidgetRef ref) { + if (petId == null) return const _PetRegisterForm(editing: null); + // 수정은 목록을 다 불러온 뒤에 연다 — 불러오기 전에 열려 "새 등록"으로 오인하지 않게 + final loaded = ref.watch(petListProvider).hasValue; + final pet = ref.watch(petByIdProvider(petId!)); + if (!loaded || pet == null) return const Scaffold(body: SizedBox.shrink()); + return _PetRegisterForm(editing: pet); + } } -class _PetRegisterPageState extends ConsumerState { - late final Pet? _editing = - widget.petId == null ? null : ref.read(petByIdProvider(widget.petId!)); +class _PetRegisterForm extends ConsumerStatefulWidget { + const _PetRegisterForm({required this.editing}); + + /// 수정 중인 아이 — 새로 등록할 때는 null + final Pet? editing; + + @override + ConsumerState<_PetRegisterForm> createState() => _PetRegisterFormState(); +} + +class _PetRegisterFormState extends ConsumerState<_PetRegisterForm> { + late final Pet? _editing = widget.editing; late final _name = TextEditingController(text: _editing?.name); late final _weight = TextEditingController( @@ -177,6 +194,8 @@ class _PetRegisterPageState extends ConsumerState { weight: double.parse(_weight.text), weightUnit: _unit, photoPath: _photoPath, + // 등록번호는 이 화면에서 입력받지 않는다 — 수정 시 기존 값을 유지 + microchip: _editing?.microchip, bloodType: _nullIfBlank(_blood.text), vet: _nullIfBlank(_vet.text), memo: _nullIfBlank(_memo.text), @@ -241,6 +260,8 @@ class _PetRegisterPageState extends ConsumerState { ); if (!ok || !mounted) return; await ref.read(petListProvider.notifier).deletePet(editing.id); + // 아이의 펫 명함도 함께 지운다 (일정은 일정 기능을 만들 때 함께 정리한다) + await ref.read(petCardsProvider.notifier).deleteForPet(editing.id); ref.read(toastProvider.notifier).show('반려동물을 삭제했어요'); if (mounted) context.go(AppRoutes.myPage); } diff --git a/flutter/lib/features/pet/presentation/widgets/pet_picker.dart b/flutter/lib/features/pet/presentation/widgets/pet_picker.dart new file mode 100644 index 0000000..936924d --- /dev/null +++ b/flutter/lib/features/pet/presentation/widgets/pet_picker.dart @@ -0,0 +1,73 @@ +import 'package:flutter/material.dart'; +import 'package:tma_app/core/constants/app_colors.dart'; +import 'package:tma_app/core/constants/app_sizes.dart'; +import 'package:tma_app/features/pet/domain/pet.dart'; +import 'package:tma_app/features/pet/presentation/widgets/pet_avatar.dart'; + +/// 원형 사진 치수 — 시안 .rup-petpick__ring 58 / 아바타 46 +const double _kRingSize = 58; +const double _kAvatarSize = 46; +const double _kRingBorder = 2.5; + +/// 가로로 넘기는 아이 목록 (시안 .rup-petpick) — 고른 아이는 주황 테두리 +class PetPicker extends StatelessWidget { + const PetPicker({super.key, required this.pets, required this.selectedId, required this.onSelect}); + + final List pets; + final String? selectedId; + final ValueChanged onSelect; + + @override + Widget build(BuildContext context) { + final textTheme = Theme.of(context).textTheme; + return SingleChildScrollView( + scrollDirection: Axis.horizontal, + clipBehavior: Clip.none, + padding: const EdgeInsets.fromLTRB(2, AppSpacing.xs, 2, AppSpacing.sm), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (final (i, pet) in pets.indexed) ...[ + if (i > 0) const SizedBox(width: 14), + Semantics( + button: true, + selected: pet.id == selectedId, + label: pet.name, + excludeSemantics: true, + child: GestureDetector( + onTap: () => onSelect(pet.id), + child: Column( + children: [ + AnimatedContainer( + duration: AppDurations.fast, + width: _kRingSize, + height: _kRingSize, + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all( + color: pet.id == selectedId ? AppColors.primary : Colors.transparent, + width: _kRingBorder, + ), + ), + alignment: Alignment.center, + child: PetAvatar(pet: pet, size: _kAvatarSize), + ), + const SizedBox(height: 7), + Text( + pet.name, + style: textTheme.bodySmall!.copyWith( + fontSize: 12.5, + fontWeight: pet.id == selectedId ? FontWeight.w700 : FontWeight.w600, + color: pet.id == selectedId ? AppColors.primary600 : AppColors.muted, + ), + ), + ], + ), + ), + ), + ], + ], + ), + ); + } +} diff --git a/flutter/lib/features/pet_card/data/pet_card_repository.dart b/flutter/lib/features/pet_card/data/pet_card_repository.dart new file mode 100644 index 0000000..1c93753 --- /dev/null +++ b/flutter/lib/features/pet_card/data/pet_card_repository.dart @@ -0,0 +1,110 @@ +import 'dart:convert'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:tma_app/core/storage/preferences_provider.dart'; +import 'package:tma_app/features/pet_card/domain/card_style.dart'; +import 'package:tma_app/features/pet_card/domain/pet_card.dart'; + +/// 펫 명함 저장소 계약 — 백엔드 API 가 생기면 Dio 구현으로 바꾼다. +abstract interface class PetCardRepository { + Future> fetchCards(); + + Future create(String petId, CardStyle style); + + Future update(String cardId, CardStyle style); + + Future delete(String cardId); + + /// 아이별 대표 명함 (petId → cardId) + Future> fetchRepresentatives(); + + Future setRepresentative(String petId, String cardId); + + Future 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 _read() { + final raw = _prefs.getString(_kCardsKey); + if (raw == null) return []; + return (jsonDecode(raw) as List) + .whereType>() + .map(PetCard.fromJson) + .toList(); + } + + Future _write(List cards) => + _prefs.setString(_kCardsKey, jsonEncode([for (final c in cards) c.toJson()])); + + Map _readRep() { + final raw = _prefs.getString(_kRepKey); + if (raw == null) return {}; + return (jsonDecode(raw) as Map).map((k, v) => MapEntry(k, v! as String)); + } + + @override + Future> fetchCards() async => _read(); + + @override + Future 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 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 delete(String cardId) async { + await _write(_read().where((c) => c.id != cardId).toList()); + } + + @override + Future> fetchRepresentatives() async => _readRep(); + + @override + Future setRepresentative(String petId, String cardId) => + _prefs.setString(_kRepKey, jsonEncode({..._readRep(), petId: cardId})); + + @override + Future clearRepresentative(String petId) => + _prefs.setString(_kRepKey, jsonEncode({..._readRep()}..remove(petId))); +} + +/// 명함 배경으로 쓰려고 추가한 내 사진 (최대 [kMaxUserBackgrounds]장) — 다음에 명함을 꾸밀 때도 남는다 +const int kMaxUserBackgrounds = 5; + +class CardBackgroundRepository { + CardBackgroundRepository(this._prefs); + + final SharedPreferences _prefs; + + List load() => _prefs.getStringList(_kBgImagesKey) ?? const []; + + Future save(List paths) => _prefs.setStringList(_kBgImagesKey, paths); +} + +final petCardRepositoryProvider = Provider((ref) { + return LocalPetCardRepository(ref.watch(sharedPreferencesProvider)); +}); + +final cardBackgroundRepositoryProvider = Provider((ref) { + return CardBackgroundRepository(ref.watch(sharedPreferencesProvider)); +}); diff --git a/flutter/lib/features/pet_card/domain/card_style.dart b/flutter/lib/features/pet_card/domain/card_style.dart new file mode 100644 index 0000000..a9a4e00 --- /dev/null +++ b/flutter/lib/features/pet_card/domain/card_style.dart @@ -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 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 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 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 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, + ); + } +} diff --git a/flutter/lib/features/pet_card/domain/pet_card.dart b/flutter/lib/features/pet_card/domain/pet_card.dart new file mode 100644 index 0000000..67193e9 --- /dev/null +++ b/flutter/lib/features/pet_card/domain/pet_card.dart @@ -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 toJson() => {'id': id, 'petId': petId, 'style': style.toJson()}; + + factory PetCard.fromJson(Map json) => PetCard( + id: json['id']! as String, + petId: json['petId']! as String, + style: CardStyle.fromJson((json['style'] as Map?) ?? const {}), + ); +} diff --git a/flutter/lib/features/pet_card/domain/pet_card_facts.dart b/flutter/lib/features/pet_card/domain/pet_card_facts.dart new file mode 100644 index 0000000..e6e8e5b --- /dev/null +++ b/flutter/lib/features/pet_card/domain/pet_card_facts.dart @@ -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'; +} diff --git a/flutter/lib/features/pet_card/presentation/pages/pet_card_detail_page.dart b/flutter/lib/features/pet_card/presentation/pages/pet_card_detail_page.dart new file mode 100644 index 0000000..1cd3944 --- /dev/null +++ b/flutter/lib/features/pet_card/presentation/pages/pet_card_detail_page.dart @@ -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 createState() => _PetCardDetailPageState(); +} + +class _PetCardDetailPageState extends ConsumerState { + CardViewMode _mode = CardViewMode.detail; + final _capture = CaptureController(); + + Future _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 _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 _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( + 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), + ), + ), + ), + ); + } +} diff --git a/flutter/lib/features/pet_card/presentation/pages/pet_card_editor_page.dart b/flutter/lib/features/pet_card/presentation/pages/pet_card_editor_page.dart new file mode 100644 index 0000000..4b89f06 --- /dev/null +++ b/flutter/lib/features/pet_card/presentation/pages/pet_card_editor_page.dart @@ -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> { + @override + List build() => const []; + + void push(Color c) => state = [c, ...state.where((x) => x != c)].take(_kMaxRecentColors).toList(); +} + +final recentColorsProvider = NotifierProvider>(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 _pickImage() => ImagePicker().pickImage( + source: ImageSource.gallery, + maxWidth: _kPhotoMaxSide, + maxHeight: _kPhotoMaxSide, + imageQuality: _kPhotoQuality, + ); + + /// 사진의 카메라 버튼 — 반려동물 사진 자체를 바꾼다 (모든 명함에 반영) + Future _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 _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 _removeBackground(String path) async { + await ref.read(userBackgroundsProvider.notifier).remove(path); + if (mounted && _style.nameBgImage == path) { + setState(() => _style = _style.withBackgroundImage(null)); + } + } + + Future _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 _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), + ), + ], + ), + ); + } +} diff --git a/flutter/lib/features/pet_card/presentation/pages/pet_card_manage_page.dart b/flutter/lib/features/pet_card/presentation/pages/pet_card_manage_page.dart new file mode 100644 index 0000000..88d4b7e --- /dev/null +++ b/flutter/lib/features/pet_card/presentation/pages/pet_card_manage_page.dart @@ -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 createState() => _PetCardManagePageState(); +} + +class _PetCardManagePageState extends ConsumerState { + /// null 이면 전체 + String? _filterPetId; + + Future _create(List 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 []; + 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), + ), + ], + ), + ), + ), + ); + } +} diff --git a/flutter/lib/features/pet_card/presentation/pages/pet_cards_page.dart b/flutter/lib/features/pet_card/presentation/pages/pet_cards_page.dart new file mode 100644 index 0000000..2a9a582 --- /dev/null +++ b/flutter/lib/features/pet_card/presentation/pages/pet_cards_page.dart @@ -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 createState() => _PetCardsPageState(); +} + +class _PetCardsPageState extends ConsumerState { + String? _selectedPetId; + final _capture = CaptureController(); + + @override + Widget build(BuildContext context) { + final pets = ref.watch(petListProvider).value ?? const []; + 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 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.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), + ), + ), + ), + ], + ], + ), + ), + ), + ); + } +} diff --git a/flutter/lib/features/pet_card/presentation/providers/pet_card_providers.dart b/flutter/lib/features/pet_card/presentation/providers/pet_card_providers.dart new file mode 100644 index 0000000..14e4be4 --- /dev/null +++ b/flutter/lib/features/pet_card/presentation/providers/pet_card_providers.dart @@ -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 cards; + final Map representatives; + + PetCardState copyWith({List? cards, Map? representatives}) => + PetCardState( + cards: cards ?? this.cards, + representatives: representatives ?? this.representatives, + ); +} + +class PetCardNotifier extends AsyncNotifier { + PetCardRepository get _repo => ref.read(petCardRepositoryProvider); + + @override + Future 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 create(String petId, CardStyle style) async { + final card = await _repo.create(petId, style); + _emit(_current.copyWith(cards: [..._current.cards, card])); + return card; + } + + Future 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 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 deleteForPet(String petId) async { + for (final card in _current.cards.where((c) => c.petId == petId).toList()) { + await delete(card.id); + } + } + + Future 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.new); + +/// 지금 등록된 아이들의 명함만 (지워진 아이의 명함은 숨긴다) +final myCardsProvider = Provider>((ref) { + final cards = ref.watch(petCardsProvider).value?.cards ?? const []; + final petIds = {for (final p in ref.watch(petListProvider).value ?? const []) p.id}; + return cards.where((c) => petIds.contains(c.petId)).toList(); +}); + +final cardByIdProvider = Provider.family((ref, cardId) { + return ref.watch(myCardsProvider).where((c) => c.id == cardId).firstOrNull; +}); + +/// 아이의 대표 명함 — 지정한 것이 없으면 그 아이의 첫 명함 +final representativeCardProvider = Provider.family((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> { + @override + List build() => ref.watch(cardBackgroundRepositoryProvider).load(); + + Future add(String path) async { + if (state.length >= kMaxUserBackgrounds || state.contains(path)) return; + state = [...state, path]; + await ref.read(cardBackgroundRepositoryProvider).save(state); + } + + Future remove(String path) async { + state = state.where((p) => p != path).toList(); + await ref.read(cardBackgroundRepositoryProvider).save(state); + } +} + +final userBackgroundsProvider = NotifierProvider>( + UserBackgroundsNotifier.new, +); diff --git a/flutter/lib/features/pet_card/presentation/widgets/card_background_image.dart b/flutter/lib/features/pet_card/presentation/widgets/card_background_image.dart new file mode 100644 index 0000000..97b98e5 --- /dev/null +++ b/flutter/lib/features/pet_card/presentation/widgets/card_background_image.dart @@ -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); + } +} diff --git a/flutter/lib/features/pet_card/presentation/widgets/card_style_controls.dart b/flutter/lib/features/pet_card/presentation/widgets/card_style_controls.dart new file mode 100644 index 0000000..0959549 --- /dev/null +++ b/flutter/lib/features/pet_card/presentation/widgets/card_style_controls.dart @@ -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 _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 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 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 userImages; + final ValueChanged onChanged; + final VoidCallback onAdd; + final ValueChanged onRemove; + + @override + Widget build(BuildContext context) { + final isDefault = style.nameBgImage == null; + final tiles = [ + _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 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 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), + ], + ), + ], + ), + ); + } +} diff --git a/flutter/lib/features/pet_card/presentation/widgets/color_pick_sheet.dart b/flutter/lib/features/pet_card/presentation/widgets/color_pick_sheet.dart new file mode 100644 index 0000000..7f3ad5a --- /dev/null +++ b/flutter/lib/features/pet_card/presentation/widgets/color_pick_sheet.dart @@ -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 _kTunedLightness = [.34, .52, .70, .86]; + +/// 앱 안에서 여는 색 고르기 시트 (시안 ColorPickSheet) — 고르면 Color 를 돌려준다 +Future showColorPickSheet( + BuildContext context, { + required String title, + required Color initial, + List recent = const [], +}) { + return showRupSheet( + 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 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 colors; + final int columns; + final double gap; + final Color selected; + final ValueChanged 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, + ), + ), + ), + ), + ), + ), + ], + ); + } +} diff --git a/flutter/lib/features/pet_card/presentation/widgets/crown_mark.dart b/flutter/lib/features/pet_card/presentation/widgets/crown_mark.dart new file mode 100644 index 0000000..61e1a1d --- /dev/null +++ b/flutter/lib/features/pet_card/presentation/widgets/crown_mark.dart @@ -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 = '' + '' + ''; + + @override + Widget build(BuildContext context) { + return SvgPicture.string( + _svg, + width: size, + height: size, + theme: SvgTheme(currentColor: color), + excludeFromSemantics: true, + ); + } +} diff --git a/flutter/lib/features/pet_card/presentation/widgets/name_card.dart b/flutter/lib/features/pet_card/presentation/widgets/name_card.dart new file mode 100644 index 0000000..811743f --- /dev/null +++ b/flutter/lib/features/pet_card/presentation/widgets/name_card.dart @@ -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 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 = + '' + '' + ''; + +/// 글자색 묶음 — 어두운 글자(기본) / 밝은 글자(어두운 배경용) +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), + ]), + ); + } +} diff --git a/flutter/lib/features/pet_card/presentation/widgets/pet_id_card.dart b/flutter/lib/features/pet_card/presentation/widgets/pet_id_card.dart new file mode 100644 index 0000000..007701d --- /dev/null +++ b/flutter/lib/features/pet_card/presentation/widgets/pet_id_card.dart @@ -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 _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}) => + '' + '' + ''; + + @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 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)), + ], + ), + ); + } +} diff --git a/flutter/lib/features/pet_card/presentation/widgets/pick_pet_sheet.dart b/flutter/lib/features/pet_card/presentation/widgets/pick_pet_sheet.dart new file mode 100644 index 0000000..d2ce675 --- /dev/null +++ b/flutter/lib/features/pet_card/presentation/widgets/pick_pet_sheet.dart @@ -0,0 +1,73 @@ +import 'package:flutter/material.dart'; +import 'package:tma_app/core/constants/app_colors.dart'; +import 'package:tma_app/core/constants/app_sizes.dart'; +import 'package:tma_app/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 showPickPetSheet(BuildContext context, {required List pets}) { + return showRupSheet( + 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), + ], + ), + ); + }, + ); +} diff --git a/flutter/lib/features/pet_card/presentation/widgets/share_sheet.dart b/flutter/lib/features/pet_card/presentation/widgets/share_sheet.dart new file mode 100644 index 0000000..fed7b1f --- /dev/null +++ b/flutter/lib/features/pet_card/presentation/widgets/share_sheet.dart @@ -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 showPetCardShareSheet( + BuildContext context, { + required String petName, + required String codeSeed, + required InlineSpan who, + required Future Function() capture, +}) { + return showRupSheet( + context, + title: '$petName 펫 명함 공유', + builder: (context) => _ShareBody( + petName: petName, + code: petCardCode(codeSeed), + who: who, + capture: capture, + ), + ); +} + +Future _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 Function() capture; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final textTheme = Theme.of(context).textTheme; + final toast = ref.read(toastProvider.notifier); + + Future saveImage() async { + final bytes = await capture(); + if (context.mounted) Navigator.of(context).pop(); + if (bytes != null) await _sharePng(bytes, 'rup_${petName}_명함.png'); + } + + Future 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 _showQr(BuildContext context, {required String petName, required String code}) { + final capture = CaptureController(); + return showDialog( + 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), + ), + ), + ), + ), + ], + ), + ), + ), + ); + }, + ); +} diff --git a/flutter/lib/shared/widgets/capture_boundary.dart b/flutter/lib/shared/widgets/capture_boundary.dart new file mode 100644 index 0000000..53fab66 --- /dev/null +++ b/flutter/lib/shared/widgets/capture_boundary.dart @@ -0,0 +1,32 @@ +import 'dart:typed_data'; +import 'dart:ui' as ui; + +import 'package:flutter/rendering.dart'; +import 'package:flutter/widgets.dart'; + +/// 캡처 해상도 — 공유 이미지가 흐리지 않게 기기 배율보다 높게 잡는다 +const double _kCapturePixelRatio = 3; + +/// 자식 위젯을 PNG 로 떠 낼 수 있게 감싼다 — 명함·QR 이미지 저장/공유용 +class CaptureController { + final GlobalKey _key = GlobalKey(); + + Future capturePng() async { + final boundary = _key.currentContext?.findRenderObject(); + if (boundary is! RenderRepaintBoundary) return null; + final image = await boundary.toImage(pixelRatio: _kCapturePixelRatio); + final data = await image.toByteData(format: ui.ImageByteFormat.png); + image.dispose(); + return data?.buffer.asUint8List(); + } +} + +class CaptureBoundary extends StatelessWidget { + const CaptureBoundary({super.key, required this.controller, required this.child}); + + final CaptureController controller; + final Widget child; + + @override + Widget build(BuildContext context) => RepaintBoundary(key: controller._key, child: child); +} diff --git a/flutter/lib/shared/widgets/rup_button.dart b/flutter/lib/shared/widgets/rup_button.dart index 0d77ead..ebc3b2c 100644 --- a/flutter/lib/shared/widgets/rup_button.dart +++ b/flutter/lib/shared/widgets/rup_button.dart @@ -2,6 +2,7 @@ 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/shared/widgets/icons/rup_icon.dart'; import 'package:tma_app/shared/widgets/pressable.dart'; /// 시안 `.rup-btn` 변형 @@ -18,6 +19,9 @@ enum RupButtonVariant { /// 파괴적 동작 (삭제 확인) danger, + /// 병원 분류색 빨강 (명함 삭제 확인 — 시안 background: var(--cat-hospital)) + alert, + /// 주황 화면 위의 흰 버튼 (인트로·온보딩 CTA — .rup-btn 이 아니라 본문 서체) light, } @@ -28,6 +32,40 @@ const double _kPadV = 13; const double _kPadVLarge = 16; const double _kPadH = 18; +/// 머리 오른쪽의 작은 연주황 버튼 — "+ 명함 만들기" 등 +class RupSoftPillButton extends StatelessWidget { + const RupSoftPillButton({super.key, required this.label, required this.onPressed, this.showPlus = true}); + + final String label; + final VoidCallback onPressed; + final bool showPlus; + + @override + Widget build(BuildContext context) { + return Pressable( + onTap: onPressed, + child: Container( + padding: EdgeInsets.symmetric(horizontal: 14, vertical: showPlus ? AppSpacing.sm : 9), + decoration: BoxDecoration( + color: AppColors.surface2, + borderRadius: BorderRadius.circular(AppRadius.pill), + border: Border.all(color: AppColors.primary100, width: 1.5), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (showPlus) ...[ + const RupIcon(RupIconName.plus, size: 17, stroke: 2.2, color: AppColors.primary600), + const SizedBox(width: 7), + ], + Text(label, style: AppTypography.button(showPlus ? 13.5 : 13, color: AppColors.primary600)), + ], + ), + ), + ); + } +} + /// 시안의 pill 버튼. 기본은 가로를 꽉 채운다(.rup-btn--block). class RupButton extends StatelessWidget { const RupButton({ @@ -78,6 +116,7 @@ class RupButton extends StatelessWidget { AppColors.primary100, ), RupButtonVariant.danger => (AppColors.danger, Colors.white, null), + RupButtonVariant.alert => (AppColors.catHospital, Colors.white, AppColors.catHospital), RupButtonVariant.light => (AppColors.offWhite, AppColors.ink, null), }; final size = large ? _kFontSizeLarge : _kFontSize; diff --git a/flutter/lib/shared/widgets/rup_dialog.dart b/flutter/lib/shared/widgets/rup_dialog.dart index 21528c8..71dd9ad 100644 --- a/flutter/lib/shared/widgets/rup_dialog.dart +++ b/flutter/lib/shared/widgets/rup_dialog.dart @@ -6,6 +6,15 @@ import 'package:tma_app/shared/widgets/rup_button.dart'; const double _kDialogMaxWidth = 300; +/// 확인창 버튼 배치 +enum RupConfirmLayout { + /// 확인 버튼 아래 "취소" 글자 (반려동물 삭제 등) + stacked, + + /// 취소 · 확인 두 버튼 나란히 (명함 삭제 등) + row, +} + /// 가운데 확인창 (시안 `` + 카드) — 확인하면 true Future showRupConfirm( BuildContext context, { @@ -14,61 +23,93 @@ Future showRupConfirm( required String confirmLabel, String cancelLabel = '취소', bool destructive = false, + RupConfirmLayout layout = RupConfirmLayout.stacked, }) async { final result = await showDialog( context: context, useRootNavigator: true, barrierColor: AppColors.scrim, - builder: (context) => Dialog( - backgroundColor: Colors.transparent, - elevation: 0, - insetPadding: const EdgeInsets.all(AppSpacing.xl), - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: _kDialogMaxWidth), - child: Container( - padding: const EdgeInsets.fromLTRB(22, 24, 22, 18), - decoration: BoxDecoration( - color: AppColors.surface, - borderRadius: BorderRadius.circular(AppRadius.card), - border: Border.all(color: AppColors.line2), - boxShadow: AppShadows.sm, - ), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Text( - title, - textAlign: TextAlign.center, - style: Theme.of(context).textTheme.titleMedium, - ), - const SizedBox(height: AppSpacing.sm), - Text( - message, - textAlign: TextAlign.center, - style: Theme.of(context).textTheme.bodyMedium!.copyWith(color: AppColors.muted), - ), - const SizedBox(height: 18), - RupButton( - label: confirmLabel, - large: true, - variant: destructive ? RupButtonVariant.danger : RupButtonVariant.primary, - onPressed: () => Navigator.of(context).pop(true), - ), - const SizedBox(height: AppSpacing.sm), - TextButton( - onPressed: () => Navigator.of(context).pop(false), - style: TextButton.styleFrom(foregroundColor: AppColors.muted), - child: Text( - cancelLabel, - style: Theme.of(context).textTheme.labelMedium!.copyWith(color: AppColors.muted), + builder: (context) { + final textTheme = Theme.of(context).textTheme; + final row = layout == RupConfirmLayout.row; + return Dialog( + backgroundColor: Colors.transparent, + elevation: 0, + insetPadding: const EdgeInsets.all(AppSpacing.xl), + child: ConstrainedBox( + constraints: BoxConstraints(maxWidth: row ? double.infinity : _kDialogMaxWidth), + child: Container( + padding: row ? const EdgeInsets.all(18) : const EdgeInsets.fromLTRB(22, 24, 22, 18), + decoration: BoxDecoration( + color: AppColors.surface, + borderRadius: BorderRadius.circular(AppRadius.card), + border: Border.all(color: AppColors.line2), + boxShadow: AppShadows.sm, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (row) const SizedBox(height: AppSpacing.xs), + Text( + title, + textAlign: TextAlign.center, + style: row + ? textTheme.titleSmall!.copyWith(fontSize: 16.5, fontWeight: FontWeight.w800) + : textTheme.titleMedium, ), - ), - ], + const SizedBox(height: AppSpacing.sm), + Text( + message, + textAlign: TextAlign.center, + style: row + ? textTheme.bodySmall!.copyWith(fontSize: 13, height: 1.55, color: AppColors.ink2) + : textTheme.bodyMedium!.copyWith(color: AppColors.muted), + ), + const SizedBox(height: 18), + if (row) + Row( + children: [ + Expanded( + child: RupButton( + label: cancelLabel, + variant: RupButtonVariant.ghost, + onPressed: () => Navigator.of(context).pop(false), + ), + ), + const SizedBox(width: 10), + Expanded( + child: RupButton( + label: confirmLabel, + variant: destructive ? RupButtonVariant.alert : RupButtonVariant.primary, + onPressed: () => Navigator.of(context).pop(true), + ), + ), + ], + ) + else ...[ + RupButton( + label: confirmLabel, + large: true, + variant: destructive ? RupButtonVariant.danger : RupButtonVariant.primary, + onPressed: () => Navigator.of(context).pop(true), + ), + const SizedBox(height: AppSpacing.sm), + TextButton( + onPressed: () => Navigator.of(context).pop(false), + style: TextButton.styleFrom(foregroundColor: AppColors.muted), + child: Text( + cancelLabel, + style: textTheme.labelMedium!.copyWith(color: AppColors.muted), + ), + ), + ], + ], + ), ), ), - ), - ), + ); + }, ); return result ?? false; } diff --git a/flutter/lib/shared/widgets/rup_logo.dart b/flutter/lib/shared/widgets/rup_logo.dart new file mode 100644 index 0000000..ef9b216 --- /dev/null +++ b/flutter/lib/shared/widgets/rup_logo.dart @@ -0,0 +1,34 @@ +import 'package:flutter/widgets.dart'; +import 'package:tma_app/core/constants/app_assets.dart'; +import 'package:tma_app/shared/widgets/drop_shadow_image.dart'; + +/// 로고 원본 비율 (307×154) +const double _kLogoAspect = 307 / 154; + +/// rup 워드마크 — 색 배경 위에서는 흰색([white]) + 옅은 그림자 +class RupLogo extends StatelessWidget { + const RupLogo({super.key, required this.height, this.white = false}); + + final double height; + final bool white; + + @override + Widget build(BuildContext context) { + return Semantics( + label: 'rup', + image: true, + child: SizedBox( + height: height, + width: height * _kLogoAspect, + child: white + ? const DropShadowImage( + asset: AppAssets.logoWhite, + shadowColor: Color(0x47783C14), // rgba(120,60,20,.28) + offset: Offset(0, 1), + blur: 3, + ) + : Image.asset(AppAssets.logo, fit: BoxFit.contain), + ), + ); + } +} diff --git a/flutter/lib/shared/widgets/rup_switch.dart b/flutter/lib/shared/widgets/rup_switch.dart new file mode 100644 index 0000000..20584d9 --- /dev/null +++ b/flutter/lib/shared/widgets/rup_switch.dart @@ -0,0 +1,52 @@ +import 'package:flutter/material.dart'; +import 'package:tma_app/core/constants/app_colors.dart'; +import 'package:tma_app/core/constants/app_sizes.dart'; + +const double _kWidth = 48; +const double _kHeight = 28; +const double _kKnob = 22; +const double _kInset = 3; + +/// 켜고 끄는 스위치 (시안 role="switch" 버튼 — 48×28, 주황/연베이지) +class RupSwitch extends StatelessWidget { + const RupSwitch({super.key, required this.value, required this.onChanged, required this.label}); + + final bool value; + final ValueChanged onChanged; + final String label; + + @override + Widget build(BuildContext context) { + return Semantics( + toggled: value, + label: label, + button: true, + child: GestureDetector( + onTap: () => onChanged(!value), + child: AnimatedContainer( + duration: AppDurations.fast, + width: _kWidth, + height: _kHeight, + decoration: BoxDecoration( + color: value ? AppColors.primary : AppColors.line2, + borderRadius: BorderRadius.circular(AppRadius.pill), + ), + child: AnimatedAlign( + duration: AppDurations.fast, + alignment: value ? Alignment.centerRight : Alignment.centerLeft, + child: Container( + width: _kKnob, + height: _kKnob, + margin: const EdgeInsets.all(_kInset), + decoration: const BoxDecoration( + color: Colors.white, + shape: BoxShape.circle, + boxShadow: [BoxShadow(color: Color(0x40000000), offset: Offset(0, 1), blurRadius: 3)], + ), + ), + ), + ), + ), + ); + } +} diff --git a/flutter/lib/shared/widgets/seg_toggle.dart b/flutter/lib/shared/widgets/seg_toggle.dart new file mode 100644 index 0000000..f9c1684 --- /dev/null +++ b/flutter/lib/shared/widgets/seg_toggle.dart @@ -0,0 +1,92 @@ +import 'package:flutter/material.dart'; +import 'package:tma_app/core/constants/app_colors.dart'; +import 'package:tma_app/core/constants/app_sizes.dart'; + +/// 선택 표시가 튕기듯 미끄러지는 곡선 — 시안 .rup-segctl__ind (.36s cubic-bezier(.34,1.42,.5,1)) +const Duration _kSlide = Duration(milliseconds: 360); +const Cubic _kSlideCurve = Cubic(.34, 1.42, .5, 1); +const double _kPad = 3; + +/// 두 개 중 하나를 고르는 작은 알약 토글 (시안 .rup-segctl--sm) — 예: 간략히 / 상세히 +class SegToggle extends StatelessWidget { + const SegToggle({ + super.key, + required this.first, + required this.second, + required this.value, + required this.onChanged, + }); + + final (T, String) first; + final (T, String) second; + final T value; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + final onFirst = value == first.$1; + final base = Theme.of(context).textTheme.bodySmall!.copyWith(fontSize: 12.5, height: 1.2); + + Widget button((T, String) option) { + final on = option.$1 == value; + return Expanded( + child: Semantics( + button: true, + selected: on, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => onChanged(option.$1), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 13, vertical: 6), + child: AnimatedDefaultTextStyle( + duration: const Duration(milliseconds: 240), + style: base.copyWith( + color: on ? Colors.white : AppColors.ink2, + fontWeight: on ? FontWeight.w700 : FontWeight.w600, + ), + child: Text(option.$2, textAlign: TextAlign.center, maxLines: 1), + ), + ), + ), + ), + ); + } + + return Container( + padding: const EdgeInsets.all(_kPad), + decoration: BoxDecoration( + color: AppColors.surface2, + borderRadius: BorderRadius.circular(AppRadius.pill), + border: Border.all(color: AppColors.line2), + ), + // 두 칸의 폭을 긴 쪽에 맞춰 같게 한다 (grid 1fr 1fr) + child: IntrinsicWidth( + child: Stack( + children: [ + Positioned.fill( + child: AnimatedAlign( + duration: _kSlide, + curve: _kSlideCurve, + alignment: onFirst ? Alignment.centerLeft : Alignment.centerRight, + child: FractionallySizedBox( + widthFactor: .5, + heightFactor: 1, + child: DecoratedBox( + decoration: BoxDecoration( + color: AppColors.primary, + borderRadius: BorderRadius.circular(AppRadius.pill), + boxShadow: const [ + BoxShadow(color: Color(0x4DFF7500), offset: Offset(0, 2), blurRadius: 7), + ], + ), + ), + ), + ), + ), + Row(children: [button(first), button(second)]), + ], + ), + ), + ); + } +} diff --git a/flutter/lib/shared/widgets/sub_page_header.dart b/flutter/lib/shared/widgets/sub_page_header.dart new file mode 100644 index 0000000..039f18e --- /dev/null +++ b/flutter/lib/shared/widgets/sub_page_header.dart @@ -0,0 +1,73 @@ +import 'package:flutter/material.dart'; +import 'package:tma_app/core/constants/app_colors.dart'; +import 'package:tma_app/core/constants/app_sizes.dart'; +import 'package:tma_app/core/theme/app_typography.dart'; +import 'package:tma_app/shared/widgets/icons/rup_icon.dart'; +import 'package:tma_app/shared/widgets/pressable.dart'; + +/// 하위 화면 머리 — 뒤로 + 제목(굵게 18) · 설명 + 오른쪽 버튼 (시안: 명함 관리·나의 반려동물·설정 등) +class SubPageHeader extends StatelessWidget { + const SubPageHeader({ + super.key, + required this.title, + required this.onBack, + this.subtitle, + this.trailing, + this.displayFont = false, + }); + + final String title; + final String? subtitle; + final VoidCallback onBack; + final Widget? trailing; + + /// 제목을 제목 서체로 (시안에서 화면마다 다르다) + final bool displayFont; + + @override + Widget build(BuildContext context) { + final textTheme = Theme.of(context).textTheme; + final titleStyle = displayFont + ? const TextStyle( + fontFamily: AppFonts.display, + fontSize: 18, + fontWeight: FontWeight.w800, + height: AppTypography.displayNormalHeight, + color: AppColors.ink, + ) + : textTheme.titleSmall!.copyWith(fontSize: 18, fontWeight: FontWeight.w800, height: 1.25); + return Padding( + padding: const EdgeInsets.only(top: AppSpacing.xs, bottom: 18), + child: Row( + children: [ + Pressable( + onTap: onBack, + pressedScale: .9, + semanticLabel: '뒤로', + child: const SizedBox.square( + dimension: 38, + child: Center(child: RupIcon(RupIconName.chevL, size: 24, stroke: 2.6, color: AppColors.ink2)), + ), + ), + const SizedBox(width: 11), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, maxLines: 1, overflow: TextOverflow.ellipsis, style: titleStyle), + if (subtitle != null) ...[ + const SizedBox(height: 1), + Text( + subtitle!, + style: textTheme.bodySmall!.copyWith(color: AppColors.muted, height: AppTypography.normalHeight), + ), + ], + ], + ), + ), + ?trailing, + ], + ), + ); + } +} diff --git a/flutter/linux/flutter/generated_plugin_registrant.cc b/flutter/linux/flutter/generated_plugin_registrant.cc index 85a2413..3ccd551 100644 --- a/flutter/linux/flutter/generated_plugin_registrant.cc +++ b/flutter/linux/flutter/generated_plugin_registrant.cc @@ -8,6 +8,7 @@ #include #include +#include void fl_register_plugins(FlPluginRegistry* registry) { g_autoptr(FlPluginRegistrar) file_selector_linux_registrar = @@ -16,4 +17,7 @@ void fl_register_plugins(FlPluginRegistry* registry) { g_autoptr(FlPluginRegistrar) flutter_secure_storage_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSecureStorageLinuxPlugin"); flutter_secure_storage_linux_plugin_register_with_registrar(flutter_secure_storage_linux_registrar); + g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin"); + url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar); } diff --git a/flutter/linux/flutter/generated_plugins.cmake b/flutter/linux/flutter/generated_plugins.cmake index 7aea3ec..fbedf4a 100644 --- a/flutter/linux/flutter/generated_plugins.cmake +++ b/flutter/linux/flutter/generated_plugins.cmake @@ -5,6 +5,7 @@ list(APPEND FLUTTER_PLUGIN_LIST file_selector_linux flutter_secure_storage_linux + url_launcher_linux ) list(APPEND FLUTTER_FFI_PLUGIN_LIST diff --git a/flutter/macos/Flutter/GeneratedPluginRegistrant.swift b/flutter/macos/Flutter/GeneratedPluginRegistrant.swift index 5d64815..e6c891e 100644 --- a/flutter/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/flutter/macos/Flutter/GeneratedPluginRegistrant.swift @@ -7,10 +7,12 @@ import Foundation import file_selector_macos import flutter_secure_storage_darwin +import share_plus import shared_preferences_foundation func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) FlutterSecureStorageDarwinPlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStorageDarwinPlugin")) + SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) } diff --git a/flutter/pubspec.lock b/flutter/pubspec.lock index 8fc6540..13370a7 100644 --- a/flutter/pubspec.lock +++ b/flutter/pubspec.lock @@ -153,6 +153,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.0" + ffi_leak_tracker: + dependency: transitive + description: + name: ffi_leak_tracker + sha256: "4093d4ef9ca06ffe2786e73bfb25e22aa92112b9bb4ec941f11e3e6b61489a97" + url: "https://pub.dev" + source: hosted + version: "0.1.2" file: dependency: transitive description: @@ -193,6 +201,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.9.3+6" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be + url: "https://pub.dev" + source: hosted + version: "1.1.1" flutter: dependency: "direct main" description: flutter @@ -274,10 +290,10 @@ packages: dependency: transitive description: name: flutter_secure_storage_windows - sha256: "3b7c8e068875dfd46719ff57c90d8c459c87f2302ed6b00ff006b3c9fcad1613" + sha256: "471951813a97006d899db4948acc654a4f28c440083ea08178935ce20b173ec1" url: "https://pub.dev" source: hosted - version: "4.1.0" + version: "4.2.2" flutter_svg: dependency: "direct main" description: @@ -648,6 +664,22 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.0" + qr: + dependency: transitive + description: + name: qr + sha256: "5a1d2586170e172b8a8c8470bbbffd5eb0cd38a66c0d77155ea138d3af3a4445" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + qr_flutter: + dependency: "direct main" + description: + name: qr_flutter + sha256: "5095f0fc6e3f71d08adef8feccc8cea4f12eec18a2e31c2e8d82cb6019f4b097" + url: "https://pub.dev" + source: hosted + version: "4.1.0" riverpod: dependency: transitive description: @@ -656,6 +688,22 @@ packages: url: "https://pub.dev" source: hosted version: "3.2.1" + share_plus: + dependency: "direct main" + description: + name: share_plus + sha256: "34f00f9becd2743c1fb05363d624f9f70d37f7ccdcdda47450bc0b8c9d327b8c" + url: "https://pub.dev" + source: hosted + version: "13.3.0" + share_plus_platform_interface: + dependency: transitive + description: + name: share_plus_platform_interface + sha256: "365ef7379fc22507256adda3385152942ffce08935452bc972c2e52a0bebae41" + url: "https://pub.dev" + source: hosted + version: "7.2.0" shared_preferences: dependency: "direct main" description: @@ -845,6 +893,46 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.0" + url_launcher_linux: + dependency: transitive + description: + name: url_launcher_linux + sha256: "10f86fef4c2c43563fa6c211ff9cf757adf4d3ab762c56bd430664a947d70cd0" + url: "https://pub.dev" + source: hosted + version: "3.2.3" + url_launcher_platform_interface: + dependency: transitive + description: + name: url_launcher_platform_interface + sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + url_launcher_web: + dependency: transitive + description: + name: url_launcher_web + sha256: "85c81589622fbc87c1c683aaea164d3604a7777495a79d91e39ffcdec39ddb34" + url: "https://pub.dev" + source: hosted + version: "2.4.3" + url_launcher_windows: + dependency: transitive + description: + name: url_launcher_windows + sha256: "6c5ad3f22cd4c38e089b81963b3cd7bb83b111b2df5dce008bb066162f42e429" + url: "https://pub.dev" + source: hosted + version: "3.1.6" + uuid: + dependency: transitive + description: + name: uuid + sha256: "9b129329f58692f6e6578329498a8fe9fbe98f090beb764ffbb8ee2eadd01dcd" + url: "https://pub.dev" + source: hosted + version: "4.6.0" vector_graphics: dependency: transitive description: @@ -929,10 +1017,10 @@ packages: dependency: transitive description: name: win32 - sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e + sha256: a0b93865d5644f11cf6a8c3f6db909f1ec168958b5805f6cc684adea957cd63d url: "https://pub.dev" source: hosted - version: "5.15.0" + version: "6.4.0" xdg_directories: dependency: transitive description: diff --git a/flutter/pubspec.yaml b/flutter/pubspec.yaml index cf0e5fb..0f04621 100644 --- a/flutter/pubspec.yaml +++ b/flutter/pubspec.yaml @@ -43,6 +43,8 @@ dependencies: shared_preferences: ^2.5.5 image_picker: ^1.2.3 path_provider: ^2.1.6 + share_plus: ^13.3.0 + qr_flutter: ^4.1.0 dev_dependencies: flutter_test: @@ -77,6 +79,8 @@ flutter: - assets/svg/login/ - assets/svg/common/ - assets/svg/species/ + - assets/svg/card_bg/ + - assets/images/brand/ # 폰트 — 출처·라이선스는 assets/fonts/README.md fonts: diff --git a/flutter/test/home_register_flow_test.dart b/flutter/test/home_register_flow_test.dart index 490ac96..a2fb75b 100644 --- a/flutter/test/home_register_flow_test.dart +++ b/flutter/test/home_register_flow_test.dart @@ -1,66 +1,32 @@ import 'dart:convert'; -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:shared_preferences/shared_preferences.dart'; -import 'package:tma_app/app.dart'; -import 'package:tma_app/core/storage/preferences_provider.dart'; -import 'package:tma_app/features/pet/domain/pet.dart'; import 'package:tma_app/features/pet/presentation/pages/pet_register_page.dart'; +import 'package:tma_app/features/pet_card/presentation/pages/pet_card_detail_page.dart'; +import 'package:tma_app/features/pet_card/presentation/pages/pet_card_editor_page.dart'; -/// 로그인·온보딩까지 마친 사용자 -const Map _signedIn = { - 'entry.introSeen': true, - 'entry.onboardingSeen': true, - 'auth.demoSession': 'kakao', -}; - -Future _launchHome(WidgetTester tester, {Map extra = const {}}) async { - tester.view.physicalSize = const Size(402, 874); - tester.view.devicePixelRatio = 1; - addTearDown(tester.view.reset); - - SharedPreferences.setMockInitialValues({..._signedIn, ...extra}); - final prefs = await SharedPreferences.getInstance(); - await tester.pumpWidget( - ProviderScope( - overrides: [sharedPreferencesProvider.overrideWithValue(prefs)], - child: const RupApp(), - ), - ); - // 스플래시 이미지 디코딩 → 2.6초 뒤 홈으로 - await tester.runAsync(() => Future.delayed(const Duration(milliseconds: 300))); - await tester.pump(const Duration(seconds: 4)); - await _settle(tester); -} - -Future _settle(WidgetTester tester) async { - await tester.pump(); - await tester.pump(const Duration(milliseconds: 600)); - await tester.pump(); -} +import 'support/app_harness.dart'; void main() { testWidgets('아이가 없으면 홈에 등록 안내가 보이고, 누르면 등록 화면으로 간다', (tester) async { - await _launchHome(tester); + await launchSignedIn(tester); expect(find.text('안녕하세요, 집사님'), findsOneWidget); expect(find.text('우리 아이를 등록해 주세요'), findsOneWidget); expect(find.text('오늘은 등록된 일정이 없어요'), findsOneWidget); await tester.tap(find.text('우리 아이를 등록해 주세요')); - await _settle(tester); + await settle(tester); expect(find.byType(PetRegisterPage), findsOneWidget); }); testWidgets('기본 정보 없이 등록을 누르면 빠진 칸마다 오류를 보여 준다', (tester) async { - await _launchHome(tester); + await launchSignedIn(tester); await tester.tap(find.text('우리 아이를 등록해 주세요')); - await _settle(tester); + await settle(tester); await tester.ensureVisible(find.text('반려동물 등록').last); await tester.tap(find.text('반려동물 등록').last); - await _settle(tester); + await settle(tester); expect(find.text('이름을 입력해주세요.'), findsOneWidget); expect(find.text('종을 선택해주세요.'), findsOneWidget); @@ -69,24 +35,36 @@ void main() { }); testWidgets('등록된 아이가 있으면 아이 목록과 명함 만들기 안내가 보인다', (tester) async { - final pet = Pet.fromInput( - 'pet-1', - PetInput( - name: '콩이', - species: '강아지', - breed: '말티즈', - gender: PetGender.male, - neutered: true, - birthDate: DateTime(2021, 9, 14), - weight: 3.2, - weightUnit: WeightUnit.kg, - ), - ); - await _launchHome(tester, extra: {'pets.v1': jsonEncode([pet.toJson()])}); + final pet = samplePet(); + await launchSignedIn(tester, extra: {'pets.v1': jsonEncode([pet.toJson()])}); expect(find.text('우리 아이를 등록해 주세요'), findsNothing); expect(find.text('콩이'), findsWidgets); expect(find.text('명함 만들기'), findsOneWidget); }); + + testWidgets('홈에서 명함을 만들면 상세로 가고, 돌아오면 홈에 대표 명함이 보인다', (tester) async { + final pet = samplePet(); + await launchSignedIn(tester, extra: {'pets.v1': jsonEncode([pet.toJson()])}); + + await tester.tap(find.text('명함 만들기')); + await settle(tester); + expect(find.byType(PetCardEditorPage), findsOneWidget); + + await tester.ensureVisible(find.text('펫 명함 만들기')); + await tester.tap(find.text('펫 명함 만들기')); + await settle(tester); + expect(find.byType(PetCardDetailPage), findsOneWidget); + expect(find.text('카드 공유하기'), findsOneWidget); + // 상세히 모드 — 건강 정보가 함께 보인다 + expect(find.text('건강 정보'), findsOneWidget); + + await tester.tap(find.bySemanticsLabel('뒤로')); + await settle(tester); + expect(find.byType(PetCardDetailPage), findsNothing); + // 빈 슬롯 대신 명함(공유 버튼 포함)이 보인다 + expect(find.text('명함 만들기'), findsNothing); + expect(find.bySemanticsLabel('콩이 명함 공유'), findsOneWidget); + }); } diff --git a/flutter/test/pet_card_domain_test.dart b/flutter/test/pet_card_domain_test.dart new file mode 100644 index 0000000..7f13cf3 --- /dev/null +++ b/flutter/test/pet_card_domain_test.dart @@ -0,0 +1,138 @@ +import 'dart:ui' show Color; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:tma_app/core/utils/color_utils.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/domain/pet_card_facts.dart'; + +void main() { + group('색 변환', () { + test('#RRGGBB 와 Color 를 서로 바꿀 수 있다', () { + expect(colorFromHex('#FF7500'), const Color(0xFFFF7500)); + expect(colorFromHex('ff7500'), const Color(0xFFFF7500)); + expect(hexOf(const Color(0xFFFF7500)), '#FF7500'); + }); + + test('형식이 틀리면 null', () { + expect(colorFromHex('#FFF'), isNull); + expect(colorFromHex('red'), isNull); + expect(colorFromHex(null), isNull); + }); + + test('lighten 은 흰색과 비율만큼 섞는다 (시안 lightenHex)', () { + expect(hexOf(lighten(const Color(0xFF000000), .5)), '#808080'); + expect(hexOf(lighten(const Color(0xFFFF9B6A), 0)), '#FF9B6A'); + expect(hexOf(lighten(const Color(0xFFFF9B6A), 1)), '#FFFFFF'); + }); + }); + + group('CardStyle', () { + test('새 명함은 첫 번째 헤더색, 흰 배경으로 시작한다', () { + final s = CardStyle.initial(); + expect(s.tint, kCardTints.first.a); + expect(s.nameBg, isNull); + expect(s.nameBgImage, isNull); + expect(s.isPresetTint, isTrue); + }); + + test('배경색을 고르면 배경 이미지가 해제되고, 이미지를 고르면 배경색이 해제된다', () { + final withImage = CardStyle.initial().withBackgroundImage('assets/svg/card_bg/bg_1.svg'); + final withColor = withImage.withBackgroundColor(const Color(0xFFFFF7F1)); + expect(withColor.nameBg, const Color(0xFFFFF7F1)); + expect(withColor.nameBgImage, isNull); + + final back = withColor.withBackgroundImage('assets/svg/card_bg/bg_2.svg'); + expect(back.nameBgImage, 'assets/svg/card_bg/bg_2.svg'); + expect(back.nameBg, isNull); + }); + + test('이미지 "기본"은 배경색을 건드리지 않는다', () { + final s = CardStyle.initial().withBackgroundColor(const Color(0xFFECF7F2)).withBackgroundImage(null); + expect(s.nameBg, const Color(0xFFECF7F2)); + }); + + test('직접 고른 헤더색은 두 번째 색을 30% 밝게 만든다', () { + final s = CardStyle.initial().withCustomTint(const Color(0xFF000000)); + expect(s.isPresetTint, isFalse); + expect(hexOf(s.tint2), hexOf(lighten(const Color(0xFF000000), kCustomTintLighten))); + }); + + test('JSON 으로 저장했다가 그대로 되살릴 수 있다', () { + final s = CardStyle.initial() + .withCustomTint(const Color(0xFF123456)) + .withBackgroundColor(const Color(0xFFABCDEF)) + .withTextLight(true) + .withScrim(true) + .withScrimTone(ScrimTone.dark); + expect(CardStyle.fromJson(s.toJson()).toJson(), s.toJson()); + }); + }); + + group('명함 표기 값', () { + test('만 나이는 생일 날짜가 지나야 한 살 더한다', () { + expect(petAgeYears(DateTime(2021, 9, 14), DateTime(2026, 9, 13)), 4); + expect(petAgeYears(DateTime(2021, 9, 14), DateTime(2026, 9, 14)), 5); + }); + + test('사람 나이 환산 — 1살 15세, 2살 24세, 이후 1년에 4세, 1살 미만은 없음', () { + expect(humanAgeOf(0), isNull); + expect(humanAgeOf(1), 15); + expect(humanAgeOf(2), 24); + expect(humanAgeOf(5), 36); + }); + + test('생일은 yy.MM.dd 로 적는다', () { + expect(shortBirth(DateTime(2021, 9, 4)), '21.09.04'); + }); + + test('명함 코드는 같은 시드면 같고 0000-000 형식이다', () { + final a = petCardCode('card-1'); + expect(a, matches(RegExp(r'^\d{4}-\d{3}$'))); + expect(petCardCode('card-1'), a); + expect(petCardCode('card-2'), isNot(a)); + }); + }); + + group('LocalPetCardRepository', () { + late LocalPetCardRepository repo; + + setUp(() async { + SharedPreferences.setMockInitialValues({}); + var tick = 0; + repo = LocalPetCardRepository( + await SharedPreferences.getInstance(), + clock: () => DateTime.fromMicrosecondsSinceEpoch(++tick), + ); + }); + + test('명함을 만들고 꾸미기 값을 바꾸고 지울 수 있다', () async { + final a = await repo.create('pet-1', CardStyle.initial()); + await repo.create('pet-1', CardStyle.initial()); + await repo.update(a.id, CardStyle.initial().withTextLight(true)); + + var cards = await repo.fetchCards(); + expect(cards, hasLength(2)); + expect(cards.first.style.textLight, isTrue); + + await repo.delete(a.id); + cards = await repo.fetchCards(); + expect(cards.map((c) => c.id), isNot(contains(a.id))); + }); + + test('대표 명함을 아이별로 기억하고 지울 수 있다', () async { + final a = await repo.create('pet-1', CardStyle.initial()); + await repo.setRepresentative('pet-1', a.id); + expect(await repo.fetchRepresentatives(), {'pet-1': a.id}); + await repo.clearRepresentative('pet-1'); + expect(await repo.fetchRepresentatives(), isEmpty); + }); + + test('명함 JSON 을 그대로 되살릴 수 있다', () { + final card = PetCard(id: 'c1', petId: 'p1', style: CardStyle.initial().withScrim(true)); + expect(PetCard.fromJson(card.toJson()).toJson(), card.toJson()); + }); + }); +} diff --git a/flutter/test/support/app_harness.dart b/flutter/test/support/app_harness.dart new file mode 100644 index 0000000..97306cb --- /dev/null +++ b/flutter/test/support/app_harness.dart @@ -0,0 +1,58 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:tma_app/app.dart'; +import 'package:tma_app/core/storage/preferences_provider.dart'; +import 'package:tma_app/features/pet/domain/pet.dart'; + +/// 로그인·온보딩까지 마친 사용자 (카카오 데모 로그인) +const Map kSignedInPrefs = { + 'entry.introSeen': true, + 'entry.onboardingSeen': true, + 'auth.demoSession': 'kakao', +}; + +/// 테스트용 아이 — 콩이 (말티즈 남아, 2021.09.14) +Pet samplePet({String id = 'pet-1', String name = '콩이'}) => Pet.fromInput( + id, + PetInput( + name: name, + species: '강아지', + breed: '말티즈', + gender: PetGender.male, + neutered: true, + birthDate: DateTime(2021, 9, 14), + weight: 3.2, + weightUnit: WeightUnit.kg, + ), + ); + +/// 시안 크기 화면에서 로그인한 사용자로 앱을 띄우고 스플래시를 지나 홈까지 간다. +/// 저장소 값을 확인할 수 있게 [SharedPreferences] 를 돌려준다. +Future launchSignedIn(WidgetTester tester, {Map extra = const {}}) async { + tester.view.physicalSize = const Size(402, 874); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + + SharedPreferences.setMockInitialValues({...kSignedInPrefs, ...extra}); + final prefs = await SharedPreferences.getInstance(); + await tester.pumpWidget( + ProviderScope( + overrides: [sharedPreferencesProvider.overrideWithValue(prefs)], + child: const RupApp(), + ), + ); + // 스플래시 이미지 디코딩 → 2.6초 뒤 홈으로 + await tester.runAsync(() => Future.delayed(const Duration(milliseconds: 300))); + await tester.pump(const Duration(seconds: 4)); + await settle(tester); + return prefs; +} + +/// 화면 전환이 끝날 때까지 — 새 화면은 첫 프레임에 무대 밖(offstage)이라 몇 번 더 그린다 +Future settle(WidgetTester tester) async { + await tester.pump(); + await tester.pump(const Duration(milliseconds: 600)); + await tester.pump(); +} diff --git a/flutter/test/widget_test.dart b/flutter/test/widget_test.dart index 49814bc..f1ce398 100644 --- a/flutter/test/widget_test.dart +++ b/flutter/test/widget_test.dart @@ -109,8 +109,7 @@ void main() { await tester.tap(find.bySemanticsLabel('펫 명함')); await _settle(tester); - // 탭 라벨 + 화면 제목 - expect(find.text('펫 명함'), findsNWidgets(2)); - expect(find.byType(MaterialApp), findsOneWidget); + expect(find.text('우리 아이 명함'), findsOneWidget); + expect(find.text('받은 명함이 없어요.'), findsOneWidget); }); } diff --git a/flutter/windows/flutter/generated_plugin_registrant.cc b/flutter/windows/flutter/generated_plugin_registrant.cc index b53f20e..23bcdc8 100644 --- a/flutter/windows/flutter/generated_plugin_registrant.cc +++ b/flutter/windows/flutter/generated_plugin_registrant.cc @@ -8,10 +8,16 @@ #include #include +#include +#include void RegisterPlugins(flutter::PluginRegistry* registry) { FileSelectorWindowsRegisterWithRegistrar( registry->GetRegistrarForPlugin("FileSelectorWindows")); FlutterSecureStorageWindowsPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin")); + SharePlusWindowsPluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("SharePlusWindowsPluginCApi")); + UrlLauncherWindowsRegisterWithRegistrar( + registry->GetRegistrarForPlugin("UrlLauncherWindows")); } diff --git a/flutter/windows/flutter/generated_plugins.cmake b/flutter/windows/flutter/generated_plugins.cmake index 4873bb8..bb6d400 100644 --- a/flutter/windows/flutter/generated_plugins.cmake +++ b/flutter/windows/flutter/generated_plugins.cmake @@ -5,6 +5,8 @@ list(APPEND FLUTTER_PLUGIN_LIST file_selector_windows flutter_secure_storage_windows + share_plus + url_launcher_windows ) list(APPEND FLUTTER_FFI_PLUGIN_LIST