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