feat(flutter): 명함 받기 — QR·코드로 받아 보관함에 저장

다른 회원이 공유한 펫 명함을 받아 보관할 수 있게 한다.
- 스캐너(mobile_scanner) 또는 명함 코드 입력으로 명함을 찾고, 저장 전 확인 화면에서 메모를 남긴다
- 보관함 목록·검색·상세(보낸 사람이 공유한 모양 그대로)·삭제
- 공유 시트를 열 때 지금 보이는 모양(간략히·상세히)을 서버에 알린다
- 받기 전 사진은 명함 코드 경로(server-url:)로, 받은 뒤에는 내 소유 사본(server-file:)으로 그린다
- 기기 저장 모드에서는 받을 명함이 없으므로 안내만 띄운다

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-11 19:55:39 +09:00
parent f045e1599f
commit 7701be3795
29 changed files with 2056 additions and 88 deletions
@@ -75,7 +75,7 @@ class _HomePetsSectionState extends ConsumerState<HomePetsSection> {
child: NameCard(
pet: selected,
style: card.style,
onShare: () => unawaited(sharePetCard(context, pet: selected, card: card, capture: _capture)),
onShare: () => unawaited(sharePetCard(context, ref, pet: selected, card: card, capture: _capture)),
),
),
),
@@ -6,6 +6,7 @@ import 'package:tma_app/core/api/session_store.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/card_view_mode.dart';
import 'package:tma_app/features/pet_card/domain/pet_card.dart';
/// 펫 명함 경로 (backend PetCardsController · RepresentativeCardController)
@@ -100,6 +101,10 @@ class ApiPetCardRepository implements PetCardRepository {
@override
Future<void> clearRepresentative(String petId) => _dio.delete<void>(_representativePath(petId));
@override
Future<void> setShareMode(String cardId, CardViewMode mode) =>
_dio.put<void>('$_kCardsPath/$cardId/share-mode', data: {'mode': mode.name});
/// 서버가 아이를 지우면서 명함도 함께 지웠다
@override
Future<void> dropPet(String petId) async {}
@@ -9,6 +9,7 @@ import 'package:tma_app/core/config/app_config.dart';
import 'package:tma_app/core/storage/preferences_provider.dart';
import 'package:tma_app/features/pet_card/data/api_pet_card_repository.dart';
import 'package:tma_app/features/pet_card/domain/card_style.dart';
import 'package:tma_app/features/pet_card/domain/card_view_mode.dart';
import 'package:tma_app/features/pet_card/domain/pet_card.dart';
/// 내 명함 + 아이별 대표 명함 (petId → cardId)
@@ -29,6 +30,9 @@ abstract interface class PetCardRepository {
Future<void> clearRepresentative(String petId);
/// 공유 시트를 열 때 — 받는 사람이 볼 모양(간략히·상세히). 기기 저장 모드는 할 일이 없다
Future<void> setShareMode(String cardId, CardViewMode mode);
/// 아이를 지웠을 때 그 아이의 명함을 지운다.
/// 서버 저장소는 할 일이 없다 — 서버가 아이를 지우면서 명함도 함께 지웠다.
Future<void> dropPet(String petId);
@@ -96,6 +100,9 @@ class LocalPetCardRepository implements PetCardRepository {
@override
Future<void> clearRepresentative(String petId) => _writeRep(_readRep()..remove(petId));
@override
Future<void> setShareMode(String cardId, CardViewMode mode) async {}
@override
Future<void> dropPet(String petId) async {
await _write(_read().where((c) => c.petId != petId).toList());
@@ -0,0 +1,177 @@
import 'package:dio/dio.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:tma_app/core/api/api_paging.dart';
import 'package:tma_app/core/api/dio_provider.dart';
import 'package:tma_app/core/api/server_file.dart';
import 'package:tma_app/core/api/session_store.dart';
import 'package:tma_app/core/config/app_config.dart';
import 'package:tma_app/core/utils/color_utils.dart';
import 'package:tma_app/core/utils/date_format.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/domain/card_view_mode.dart';
import 'package:tma_app/features/pet_card/domain/received_card.dart';
/// 명함 받기 경로 (backend SharedPetCardsController · ReceivedCardsController)
const String _kSharedPath = '/shared-pet-cards';
const String _kReceivedPath = '/received-cards';
/// 받은 명함 저장소 계약 — 다른 회원의 명함을 받으므로 서버가 있어야 한다
abstract interface class ReceivedCardRepository {
/// 명함 받기를 쓸 수 있는지 — 기기 저장 모드에서는 받을 명함이 없다
bool get available;
/// 보관함 — 최근에 받은 명함이 앞
Future<List<ReceivedCard>> fetch();
/// 받기 전 미리보기
Future<SharedCard> preview(String code);
/// 보관함에 저장
Future<ReceivedCard> receive(String code, {String? note});
Future<void> delete(String id);
}
/// 기기 저장 모드 — 명함 받기를 쓸 수 없다 (화면은 안내만 띄운다)
class UnavailableReceivedCardRepository implements ReceivedCardRepository {
const UnavailableReceivedCardRepository();
@override
bool get available => false;
@override
Future<List<ReceivedCard>> fetch() async => const [];
@override
Future<SharedCard> preview(String code) => throw StateError('명함 받기는 서버 모드에서만 쓸 수 있다');
@override
Future<ReceivedCard> receive(String code, {String? note}) =>
throw StateError('명함 받기는 서버 모드에서만 쓸 수 있다');
@override
Future<void> delete(String id) async {}
}
/// 공유된 아이 정보 → 명함 위젯이 그리는 Pet (간략히면 건강 정보·병원·메모가 비어 있다)
Pet sharedPetFromApi(String id, Map<String, Object?> json, {String? photoPath}) => Pet(
id: id,
tint: colorFromHex(json['tint'] as String?) ?? kPetTints.first,
name: json['name']! as String,
species: json['species']! as String,
breed: json['breed'] as String? ?? '',
gender: PetGender.values.asNameMap()[json['gender']] ?? PetGender.male,
neutered: json['neutered'] as bool? ?? false,
birthDate: parseIsoDate(json['birthDate']! as String),
weight: (json['weight']! as num).toDouble(),
weightUnit: WeightUnit.values.asNameMap()[json['weightUnit']] ?? WeightUnit.kg,
photoPath: photoPath,
microchip: json['microchip'] as String?,
bloodType: json['bloodType'] as String?,
vet: json['vet'] as String?,
memo: json['memo'] as String?,
allergies: _strings(json['allergies']),
conditions: _strings(json['conditions']),
watchConditions: _strings(json['watchConditions']),
);
List<String> _strings(Object? value) => value is List ? value.whereType<String>().toList() : const [];
/// 공유된 꾸미기 값 → CardStyle ([backgroundPath] 는 배경 사진이 있을 때)
CardStyle sharedStyleFromApi(Map<String, Object?> json, {String? backgroundPath}) {
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['nameBgAsset'] as String?) ?? backgroundPath,
textLight: json['textLight'] as bool? ?? false,
scrim: json['scrim'] as bool? ?? false,
scrimTone: ScrimTone.values.asNameMap()[json['scrimTone']] ?? ScrimTone.light,
);
}
/// 받기 전 미리보기 — 사진은 아직 내 것이 아니라 명함 코드 경로로 받는다
SharedCard sharedCardPreviewFromApi(Map<String, Object?> json) {
final code = json['code']! as String;
String? image(String flag, String kind) =>
json[flag] == true ? serverUrlRef('$_kSharedPath/$code/images/$kind') : null;
return SharedCard(
code: code,
mode: CardViewMode.byName(json['mode'] as String?),
senderNickname: json['senderNickname'] as String?,
senderPhotoPath: image('hasSenderPhoto', 'sender'),
pet: sharedPetFromApi('shared-$code', json['pet']! as Map<String, Object?>, photoPath: image('hasPetPhoto', 'pet')),
style: sharedStyleFromApi(
json['style']! as Map<String, Object?>,
backgroundPath: image('hasBackgroundPhoto', 'background'),
),
);
}
/// 보관함의 받은 명함 — 사진은 내 소유 사본(server-file:)
ReceivedCard receivedCardFromApi(Map<String, Object?> json) {
final id = json['id']! as String;
return ReceivedCard(
id: id,
code: json['code']! as String,
mode: CardViewMode.byName(json['mode'] as String?),
senderNickname: json['senderNickname'] as String?,
senderPhotoPath: serverFileRefOrNull(json['senderPhotoFileId'] as String?),
pet: sharedPetFromApi(
'received-$id',
json['pet']! as Map<String, Object?>,
photoPath: serverFileRefOrNull(json['petPhotoFileId'] as String?),
),
style: sharedStyleFromApi(
json['style']! as Map<String, Object?>,
backgroundPath: serverFileRefOrNull(json['backgroundFileId'] as String?),
),
note: json['note'] as String?,
receivedAt: DateTime.parse(json['receivedAt']! as String).toLocal(),
);
}
/// 서버 저장소
class ApiReceivedCardRepository implements ReceivedCardRepository {
ApiReceivedCardRepository({required Dio dio, required SessionStore session})
: _dio = dio,
_session = session;
final Dio _dio;
final SessionStore _session;
@override
bool get available => true;
@override
Future<List<ReceivedCard>> fetch() async {
if (!_session.hasSession) return const [];
final cards = await fetchAllPages(_dio, _kReceivedPath, receivedCardFromApi);
// 시안: 새로 받은 명함이 위로
return cards..sort((a, b) => b.receivedAt.compareTo(a.receivedAt));
}
@override
Future<SharedCard> preview(String code) async {
final response = await _dio.get<Map<String, dynamic>>('$_kSharedPath/$code');
return sharedCardPreviewFromApi(response.data!);
}
@override
Future<ReceivedCard> receive(String code, {String? note}) async {
final response = await _dio.post<Map<String, dynamic>>(_kReceivedPath, data: {'code': code, 'note': note});
return receivedCardFromApi(response.data!);
}
@override
Future<void> delete(String id) => _dio.delete<void>('$_kReceivedPath/$id');
}
final receivedCardRepositoryProvider = Provider<ReceivedCardRepository>((ref) {
return switch (ref.watch(dataModeProvider)) {
DataMode.local => const UnavailableReceivedCardRepository(),
DataMode.api => ApiReceivedCardRepository(dio: ref.watch(dioProvider), session: ref.watch(sessionStoreProvider)),
};
});
@@ -0,0 +1,13 @@
/// 명함을 보는 모양 — 간략히(명함) / 상세히(명함 + 건강 정보·병원·메모).
/// 공유할 때도 이 모양으로 간다 — 받는 사람은 공유된 모양의 정보만 받는다.
enum CardViewMode {
simple('간략히'),
detail('상세히');
const CardViewMode(this.label);
final String label;
static CardViewMode byName(String? name) =>
CardViewMode.values.where((m) => m.name == name).firstOrNull ?? CardViewMode.simple;
}
@@ -0,0 +1,80 @@
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/domain/card_view_mode.dart';
/// 받은 명함 메모 최대 길이 (시안 maxLength 200)
const int kReceivedNoteMaxLength = 200;
/// 다른 회원이 공유한 명함 — 받기 전 미리보기와 보관함의 받은 명함이 같은 모양을 쓴다.
/// [pet] 은 공유된 모양에 담긴 정보만 채워져 있다 (간략히면 건강 정보·병원·메모가 비어 있다).
class SharedCard {
const SharedCard({
required this.code,
required this.mode,
required this.senderNickname,
required this.senderPhotoPath,
required this.pet,
required this.style,
});
final String code;
/// 보낸 사람이 공유한 모양 — 이 모양으로만 보여 준다
final CardViewMode mode;
/// 보낸 사람 닉네임 — 모르면 null
final String? senderNickname;
final String? senderPhotoPath;
final Pet pet;
final CardStyle style;
/// "초코아빠" — 닉네임이 없으면 기본 호칭
String get senderName => senderNickname ?? kUnknownSender;
}
/// 보낸 사람의 닉네임을 모를 때
const String kUnknownSender = '집사';
/// 보관함에 저장한 받은 명함 — 받는 순간의 사본이다
class ReceivedCard extends SharedCard {
const ReceivedCard({
required this.id,
required super.code,
required super.mode,
required super.senderNickname,
required super.senderPhotoPath,
required super.pet,
required super.style,
required this.note,
required this.receivedAt,
});
final String id;
/// 받은 사람이 남긴 메모
final String? note;
final DateTime receivedAt;
/// 보관함 검색 — 아이 이름·보낸 사람 (시안: name + from)
bool matches(String query) {
final q = query.trim().toLowerCase();
if (q.isEmpty) return true;
return '${pet.name} ${senderNickname ?? ''}'.toLowerCase().contains(q);
}
}
/// 명함 QR 에 담는 주소 — 앱의 스캐너가 읽는다 (시스템 카메라로 찍으면 앱을 연다)
String petCardDeepLink(String code) => 'rup://pet-card/$code';
final RegExp _kCardCode = RegExp(r'^\d{4}-\d{3}$');
/// QR 또는 직접 입력한 값에서 명함 코드를 꺼낸다 — 주소(rup://pet-card/0428-117)·코드(0428-117)·
/// 하이픈 없이 쓴 7자리(0428117) 모두 받는다. 명함 코드가 아니면 null
String? parseCardCode(String raw) {
var text = raw.trim();
const prefix = 'rup://pet-card/';
if (text.startsWith(prefix)) text = text.substring(prefix.length);
text = text.split('?').first.trim();
if (RegExp(r'^\d{7}$').hasMatch(text)) text = '${text.substring(0, 4)}-${text.substring(4)}';
return _kCardCode.hasMatch(text) ? text : null;
}
@@ -0,0 +1,463 @@
import 'dart:async';
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:mobile_scanner/mobile_scanner.dart';
import 'package:tma_app/core/api/api_guard.dart';
import 'package:tma_app/core/api/error_lexicon.dart';
import 'package:tma_app/core/constants/app_colors.dart';
import 'package:tma_app/core/theme/app_typography.dart';
import 'package:tma_app/features/pet_card/domain/received_card.dart';
import 'package:tma_app/features/pet_card/presentation/providers/received_card_providers.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';
// 시안 .rup-scanner 치수·색
const Color _kScannerBg = Color(0xFF14110E);
const Color _kDim = Color(0x99120F0C); // rgba(18,15,12,.6)
const Color _kFoundGreen = Color(0xFF4ED08A);
const double _kFrame = 244;
const double _kFrameRadius = 22;
const double _kCorner = 36;
const double _kCornerStroke = 4;
const double _kCloseSize = 38;
const double _kCheckSize = 70;
/// 스캔 줄이 위아래로 한 번 오가는 시간 (시안 2.3s)
const Duration _kSweep = Duration(milliseconds: 1150);
/// 명함을 찾은 뒤 체크 표시를 보여 주는 최소 시간 (시안 820ms)
const Duration _kFoundHold = Duration(milliseconds: 820);
/// 같은 QR 을 실패한 뒤 다시 읽기까지 기다리는 시간 — 같은 알림이 계속 뜨지 않게
const Duration _kRetryGap = Duration(seconds: 3);
/// 스캐너를 닫은 결과
sealed class ScanOutcome {
const ScanOutcome();
}
/// QR 로 명함을 찾았다
final class ScanFound extends ScanOutcome {
const ScanFound(this.card);
final SharedCard card;
}
/// "명함 코드 입력하기"를 눌렀다
final class ScanTypeCode extends ScanOutcome {
const ScanTypeCode();
}
/// QR 을 읽어 알려 주는 카메라 화면
typedef ScannerCamera = Widget Function(ValueChanged<String> onCode);
/// 스캐너 카메라 — 테스트에서 실제 카메라 대신 끼운다 (null 이면 기기 카메라)
final scannerCameraProvider = Provider<ScannerCamera?>((ref) => null);
/// 명함 받기 스캐너 — 상대 명함의 QR 을 비추면 명함을 불러온다
class CardScannerPage extends ConsumerStatefulWidget {
const CardScannerPage({super.key});
@override
ConsumerState<CardScannerPage> createState() => _CardScannerPageState();
}
class _CardScannerPageState extends ConsumerState<CardScannerPage> {
late final ScannerCamera? _fakeCamera = ref.read(scannerCameraProvider);
MobileScannerController? _controller;
bool _found = false;
bool _busy = false;
String? _lastRaw;
DateTime _lastAt = DateTime.fromMillisecondsSinceEpoch(0);
@override
void initState() {
super.initState();
if (_fakeCamera == null) _controller = MobileScannerController(formats: const [BarcodeFormat.qrCode]);
}
@override
void dispose() {
unawaited(_controller?.dispose());
// 다른 화면은 상태 표시줄 모양을 따로 정하지 않는다 — 어두운 글자(앱 기본)로 되돌린다
SystemChrome.setSystemUIOverlayStyle(SystemUiOverlayStyle.dark);
super.dispose();
}
void _onDetect(BarcodeCapture capture) {
final raw = capture.barcodes.map((b) => b.rawValue).nonNulls.firstOrNull;
if (raw != null) unawaited(_onCode(raw));
}
Future<void> _onCode(String raw) async {
if (_busy) return;
final now = DateTime.now();
if (raw == _lastRaw && now.difference(_lastAt) < _kRetryGap) return;
_lastRaw = raw;
_lastAt = now;
final code = parseCardCode(raw);
if (code == null) {
// 펫 명함 QR 이 아니다 — 서버가 못 찾았을 때와 같은 안내
ref.read(toastProvider.notifier).show(ErrorLexicon.getMessage('CARD_002'), icon: RupIconName.alert);
return;
}
_busy = true;
setState(() => _found = true);
final shown = Stopwatch()..start();
try {
final card = await ref.read(receivedCardsProvider.notifier).preview(code);
final rest = _kFoundHold - shown.elapsed;
if (rest > Duration.zero) await Future<void>.delayed(rest);
if (mounted) context.pop(ScanFound(card));
} catch (e) {
if (!isReportedApiFailure(e)) rethrow;
// 실패 알림은 인터셉터가 띄웠다 — 다시 비출 수 있게 되돌린다
_lastAt = DateTime.now();
if (mounted) {
setState(() {
_found = false;
_busy = false;
});
}
}
}
Widget _camera() {
final camera = _fakeCamera;
if (camera != null) return camera(_onCode);
return MobileScanner(
controller: _controller,
onDetect: _onDetect,
// 카메라를 켜는 동안·쓸 수 없을 때는 시안의 어두운 바탕 (안내는 아래 문구가 맡는다)
placeholderBuilder: (context) => const _Feed(),
errorBuilder: (context, error) => const _Feed(),
);
}
@override
Widget build(BuildContext context) {
final top = MediaQuery.paddingOf(context).top;
final topBarHeight = top + 8 + _kCloseSize;
return AnnotatedRegion<SystemUiOverlayStyle>(
value: SystemUiOverlayStyle.light,
child: Scaffold(
backgroundColor: _kScannerBg,
body: Stack(
children: [
Positioned.fill(child: _camera()),
Positioned.fill(
child: Column(
children: [
SizedBox(height: topBarHeight),
Expanded(
child: Center(
child: _Frame(found: _found),
),
),
_Bottom(
found: _found,
camera: _controller,
onManual: () => context.pop(const ScanTypeCode()),
),
],
),
),
// 조준 프레임 바깥을 어둡게 덮는 막이 위 막대를 가리지 않게 맨 위에 둔다
Positioned(
top: top + 8,
left: 18,
right: 18,
child: _TopBar(onClose: () => context.pop()),
),
],
),
),
);
}
}
/// 카메라 화면이 아직 없을 때의 바탕 (시안 .rup-scanner__feed)
class _Feed extends StatelessWidget {
const _Feed();
@override
Widget build(BuildContext context) {
return const DecoratedBox(
decoration: BoxDecoration(
gradient: RadialGradient(
center: Alignment(0, -.2),
radius: 1.1,
colors: [Color(0xFF3A332B), Color(0xFF221D17), _kScannerBg],
stops: [0, .56, 1],
),
),
);
}
}
class _TopBar extends StatelessWidget {
const _TopBar({required this.onClose});
final VoidCallback onClose;
@override
Widget build(BuildContext context) {
return Row(
children: [
Semantics(
button: true,
label: '닫기',
child: Pressable(
pressedScale: .9,
onTap: onClose,
child: Container(
width: _kCloseSize,
height: _kCloseSize,
decoration: const BoxDecoration(color: Color(0x24FFFFFF), shape: BoxShape.circle),
alignment: Alignment.center,
child: const RupIcon(RupIconName.x, size: 20, stroke: 2.4, color: Colors.white),
),
),
),
const Expanded(
child: Text(
'명함 받기',
textAlign: TextAlign.center,
style: TextStyle(
fontFamily: AppFonts.display,
fontSize: 16,
fontWeight: FontWeight.w800,
height: AppTypography.displayNormalHeight,
color: Colors.white,
),
),
),
const SizedBox(width: _kCloseSize),
],
);
}
}
/// 조준 프레임 — 바깥은 어둡게, 모서리 표시 + 스캔 줄, 찾으면 초록 체크
class _Frame extends StatefulWidget {
const _Frame({required this.found});
final bool found;
@override
State<_Frame> createState() => _FrameState();
}
class _FrameState extends State<_Frame> with SingleTickerProviderStateMixin {
late final AnimationController _sweep = AnimationController(vsync: this, duration: _kSweep)
..repeat(reverse: true);
@override
void dispose() {
_sweep.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final found = widget.found;
return SizedBox.square(
dimension: _kFrame,
child: CustomPaint(
painter: const _DimOutside(),
child: Stack(
clipBehavior: Clip.none,
children: [
Positioned.fill(
child: TweenAnimationBuilder<Color?>(
tween: ColorTween(end: found ? _kFoundGreen : AppColors.primary),
duration: const Duration(milliseconds: 300),
builder: (context, color, _) => CustomPaint(painter: _Corners(color ?? AppColors.primary)),
),
),
if (!found)
AnimatedBuilder(
animation: _sweep,
builder: (context, child) {
final t = Curves.easeInOut.transform(_sweep.value);
return Positioned(left: 10, right: 10, top: 12 + (_kFrame - 28) * t, child: child!);
},
child: Container(
height: 2,
decoration: const BoxDecoration(
gradient: LinearGradient(colors: [Color(0x00FF7500), AppColors.primary, Color(0x00FF7500)]),
boxShadow: [BoxShadow(color: Color(0xB3FF7500), blurRadius: 14, spreadRadius: 2)],
),
),
),
Center(
child: AnimatedOpacity(
duration: const Duration(milliseconds: 250),
opacity: found ? 1 : 0,
child: AnimatedScale(
duration: const Duration(milliseconds: 400),
curve: const Cubic(.34, 1.5, .5, 1),
scale: found ? 1 : .5,
child: Container(
width: _kCheckSize,
height: _kCheckSize,
decoration: const BoxDecoration(
color: _kFoundGreen,
shape: BoxShape.circle,
boxShadow: [BoxShadow(color: Color(0x804ED08A), offset: Offset(0, 10), blurRadius: 30)],
),
alignment: Alignment.center,
child: const RupIcon(RupIconName.check, size: 36, stroke: 3, color: Colors.white),
),
),
),
),
],
),
),
);
}
}
/// 프레임 바깥 화면 전체를 어둡게 (시안 box-shadow 0 0 0 2000px)
class _DimOutside extends CustomPainter {
const _DimOutside();
@override
void paint(Canvas canvas, Size size) {
const reach = 4000.0;
final outside = Path()
..fillType = PathFillType.evenOdd
..addRect(Rect.fromLTRB(-reach, -reach, size.width + reach, size.height + reach))
..addRRect(RRect.fromRectAndRadius(Offset.zero & size, const Radius.circular(_kFrameRadius)));
canvas.drawPath(outside, Paint()..color = _kDim);
}
@override
bool shouldRepaint(_DimOutside oldDelegate) => false;
}
/// 네 모서리 표시 — 프레임 테두리를 따라 굵기 4, 길이 36
class _Corners extends CustomPainter {
const _Corners(this.color);
final Color color;
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()
..color = color
..style = PaintingStyle.stroke
..strokeWidth = _kCornerStroke
..strokeCap = StrokeCap.butt;
const r = 16.0; // 시안 바깥 반경 18 − 굵기 절반
const reach = _kCorner - _kCornerStroke / 2;
final w = size.width;
final h = size.height;
final rect = Rect.fromCircle(center: Offset.zero, radius: r);
final corners = <(Offset, double)>[
(Offset.zero, math.pi), // 왼쪽 위
(Offset(w, 0), -math.pi / 2), // 오른쪽 위
(Offset(w, h), 0), // 오른쪽 아래
(Offset(0, h), math.pi / 2), // 왼쪽 아래
];
for (final (corner, start) in corners) {
// 모서리를 원점으로 옮겨 같은 모양(가로·세로 직선 + 사분원)을 돌려 그린다
canvas
..save()
..translate(corner.dx, corner.dy)
..rotate(start - math.pi);
final path = Path()
..moveTo(0, reach)
..lineTo(0, r)
..arcTo(rect.shift(const Offset(r, r)), math.pi, math.pi / 2, false)
..lineTo(reach, 0);
canvas
..drawPath(path, paint)
..restore();
}
}
@override
bool shouldRepaint(_Corners oldDelegate) => oldDelegate.color != color;
}
/// 안내 문구 + "명함 코드 입력하기"
class _Bottom extends StatelessWidget {
const _Bottom({required this.found, required this.camera, required this.onManual});
final bool found;
/// 카메라 상태 — 쓸 수 없으면 안내 문구를 바꾼다 (테스트 카메라면 null)
final MobileScannerController? camera;
final VoidCallback onManual;
@override
Widget build(BuildContext context) {
final camera = this.camera;
final hint = camera == null
? _hint(found: found, unavailable: false)
: ValueListenableBuilder<MobileScannerState>(
valueListenable: camera,
builder: (context, state, _) => _hint(found: found, unavailable: state.error != null),
);
return Padding(
padding: EdgeInsets.fromLTRB(24, 0, 24, math.max(MediaQuery.paddingOf(context).bottom + 96, 150)),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
hint,
// 찾은 뒤에는 버튼을 숨기되 자리는 지켜 프레임이 움직이지 않게 한다
Visibility.maintain(
visible: !found,
child: Padding(
padding: const EdgeInsets.only(top: 22),
child: Pressable(
pressedScale: .96,
onTap: onManual,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 30, vertical: 15),
decoration: BoxDecoration(
color: const Color(0x33FF7500),
borderRadius: BorderRadius.circular(999),
border: Border.all(color: const Color(0x9EFF963C), width: 1.5),
boxShadow: const [BoxShadow(color: Color(0x38FF7500), offset: Offset(0, 4), blurRadius: 18)],
),
child: const Text(
'명함 코드 입력하기',
style: TextStyle(
fontFamily: AppFonts.display,
fontSize: 15.5,
fontWeight: FontWeight.w800,
height: AppTypography.displayNormalHeight,
color: Colors.white,
),
),
),
),
),
),
],
),
);
}
Widget _hint({required bool found, required bool unavailable}) {
final text = found
? '명함을 찾았어요! 정보 불러오는 중…'
: unavailable
? '카메라를 쓸 수 없어요.\n설정에서 카메라 권한을 허용하거나 명함 코드를 입력해 주세요.'
: '상대의 펫 명함 QR 코드를\n사각형 안에 비춰주세요.';
return Text(
text,
textAlign: TextAlign.center,
style: const TextStyle(fontSize: 13.5, height: 1.55, color: Color(0xD1FFFFFF)),
);
}
}
@@ -9,6 +9,7 @@ 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/card_view_mode.dart';
import 'package:tma_app/features/pet_card/domain/pet_card.dart';
import 'package:tma_app/features/pet_card/domain/pet_card_facts.dart';
import 'package:tma_app/features/pet_card/presentation/providers/pet_card_providers.dart';
@@ -23,16 +24,6 @@ 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});
@@ -47,15 +38,19 @@ class _PetCardDetailPageState extends ConsumerState<PetCardDetailPage> {
CardViewMode _mode = CardViewMode.detail;
final _capture = CaptureController();
Future<void> _share(String petName) {
/// 지금 보이는 모양(간략히·상세히)이 그대로 공유된다 — 받는 사람도 이 모양으로 본다
Future<void> _share(String petName) async {
final card = ref.read(petCardsProvider).value?.cards.where((c) => c.id == widget.cardId).firstOrNull;
final mode = _mode;
if (!await succeeded(ref.read(petCardsProvider.notifier).setShareMode(widget.cardId, mode))) return;
if (!mounted) return;
return showPetCardShareSheet(
context,
petName: petName,
code: card == null ? petCardCode(widget.cardId) : displayCardCode(card),
capture: _capture.capturePng,
who: TextSpan(children: [
TextSpan(text: '${_mode.label} 모드', style: const TextStyle(color: AppColors.primary600)),
TextSpan(text: '${mode.label} 모드', style: const TextStyle(color: AppColors.primary600)),
const TextSpan(text: ' 펫 명함으로 공유돼요'),
]),
);
@@ -118,17 +118,17 @@ class _PetCardManagePageState extends ConsumerState<PetCardManagePage> {
}
}
class _ManagedCard extends StatefulWidget {
class _ManagedCard extends ConsumerStatefulWidget {
const _ManagedCard({required this.card, required this.pet});
final PetCard card;
final Pet pet;
@override
State<_ManagedCard> createState() => _ManagedCardState();
ConsumerState<_ManagedCard> createState() => _ManagedCardState();
}
class _ManagedCardState extends State<_ManagedCard> {
class _ManagedCardState extends ConsumerState<_ManagedCard> {
final _capture = CaptureController();
@override
@@ -140,7 +140,7 @@ class _ManagedCardState extends State<_ManagedCard> {
child: NameCard(
pet: widget.pet,
style: widget.card.style,
onShare: () => unawaited(sharePetCard(context, pet: widget.pet, card: widget.card, capture: _capture)),
onShare: () => unawaited(sharePetCard(context, ref, pet: widget.pet, card: widget.card, capture: _capture)),
),
),
);
@@ -1,18 +1,25 @@
import 'dart:async';
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:tma_app/core/api/api_guard.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_avatar.dart';
import 'package:tma_app/features/pet/presentation/widgets/pet_picker.dart';
import 'package:tma_app/features/pet_card/domain/card_view_mode.dart';
import 'package:tma_app/features/pet_card/domain/pet_card.dart';
import 'package:tma_app/features/pet_card/domain/pet_card_facts.dart';
import 'package:tma_app/features/pet_card/domain/received_card.dart';
import 'package:tma_app/features/pet_card/presentation/pages/receive_card_page.dart';
import 'package:tma_app/features/pet_card/presentation/providers/pet_card_providers.dart';
import 'package:tma_app/features/pet_card/presentation/providers/received_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';
@@ -20,13 +27,18 @@ 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 _kSearchOpenMinWidth = 140;
/// 받은 명함 줄 끝 화살표 (시안 #CFC6B9)
const Color _kChevron = Color(0xFFCFC6B9);
/// 탭바 뒤로 콘텐츠가 가려지지 않게 두는 여유
const double _kBottomGap = 30;
@@ -127,7 +139,7 @@ class _RepresentativeCard extends ConsumerWidget {
child: NameCard(
pet: pet,
style: card.style,
onShare: () => unawaited(sharePetCard(context, pet: pet, card: card, capture: capture)),
onShare: () => unawaited(sharePetCard(context, ref, pet: pet, card: card, capture: capture)),
),
),
],
@@ -136,13 +148,17 @@ class _RepresentativeCard extends ConsumerWidget {
}
}
/// 목록에서 명함 공유 — 명함 아래 안내에 아이 이름을 쓴다
/// 목록에서 명함 공유 — 명함 아래 안내에 아이 이름을 쓴다.
/// 목록에 보이는 간략히 명함이 그대로 공유된다 (받는 사람도 간략히로 본다)
Future<void> sharePetCard(
BuildContext context, {
BuildContext context,
WidgetRef ref, {
required Pet pet,
required PetCard card,
required CaptureController capture,
}) {
}) async {
if (!await succeeded(ref.read(petCardsProvider.notifier).setShareMode(card.id, CardViewMode.simple))) return;
if (!context.mounted) return;
return showPetCardShareSheet(
context,
petName: pet.name,
@@ -155,7 +171,7 @@ Future<void> sharePetCard(
);
}
/// 보관된(받은) 명함 — 받기 기능은 서버 연동 후 연다
/// 보관된(받은) 명함 — 명함 받기 · 검색 · 목록
class _ReceivedSection extends ConsumerStatefulWidget {
const _ReceivedSection();
@@ -168,6 +184,13 @@ class _ReceivedSectionState extends ConsumerState<_ReceivedSection> {
final _query = TextEditingController();
final _focus = FocusNode();
@override
void initState() {
super.initState();
// 검색어가 바뀌면 목록을 다시 거른다
_query.addListener(() => setState(() {}));
}
@override
void dispose() {
_query.dispose();
@@ -191,8 +214,9 @@ class _ReceivedSectionState extends ConsumerState<_ReceivedSection> {
@override
Widget build(BuildContext context) {
final textTheme = Theme.of(context).textTheme;
// 받은 명함 저장소는 명함 받기(서버 조회)와 함께 만든다 — 지금은 항상 비어 있다
const receivedCount = 0;
final received = ref.watch(receivedCardsProvider).value ?? const <ReceivedCard>[];
final query = _query.text.trim();
final shown = received.where((c) => c.matches(query)).toList();
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
@@ -212,7 +236,7 @@ class _ReceivedSectionState extends ConsumerState<_ReceivedSection> {
duration: const Duration(milliseconds: 180),
opacity: _searchOpen ? 0 : 1,
child: Text(
'보관된 명함 $receivedCount',
'보관된 명함 ${received.length}',
style: textTheme.bodyMedium!.copyWith(fontSize: 14, fontWeight: FontWeight.w600, color: AppColors.muted),
),
),
@@ -237,7 +261,7 @@ class _ReceivedSectionState extends ConsumerState<_ReceivedSection> {
const SizedBox(width: 5),
Pressable(
pressedScale: .96,
onTap: () => ref.read(toastProvider.notifier).show('명함 받기는 곧 열릴 예정이에요', icon: RupIconName.qrcode),
onTap: () => unawaited(startReceivingCard(context, ref)),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 13, vertical: AppSpacing.sm),
decoration: BoxDecoration(
@@ -260,20 +284,78 @@ class _ReceivedSectionState extends ConsumerState<_ReceivedSection> {
),
// 시안: 제목 줄 아래 12 + 목록 위 2
const SizedBox(height: 14),
Padding(
padding: const EdgeInsets.fromLTRB(28, 64, 28, 48),
// 시안: 받은 명함이 한 장도 없으면 검색어와 상관없이 이 문구
child: Text(
'받은 명함이 없어요.',
textAlign: TextAlign.center,
style: textTheme.bodyMedium!.copyWith(fontSize: 14, color: AppColors.muted),
),
),
if (received.isEmpty)
Padding(
padding: const EdgeInsets.fromLTRB(28, 64, 28, 48),
// 시안: 받은 명함이 한 장도 없으면 검색어와 상관없이 이 문구
child: Text(
'받은 명함이 없어요.',
textAlign: TextAlign.center,
style: textTheme.bodyMedium!.copyWith(fontSize: 14, color: AppColors.muted),
),
)
else if (shown.isEmpty)
EmptyMessage("'$query' 검색 결과가 없어요.", padding: 28)
else
for (final card in shown)
_ReceivedRow(card: card, onTap: () => unawaited(context.push(AppRoutes.receivedCard(card.id)))),
const SizedBox(height: 6),
],
);
}
}
/// 받은 명함 한 줄 — 아이 사진 · 이름 · 보낸 사람 (시안 .rup-received__row)
class _ReceivedRow extends StatelessWidget {
const _ReceivedRow({required this.card, required this.onTap});
final ReceivedCard card;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final textTheme = Theme.of(context).textTheme;
final sub = textTheme.bodySmall!.copyWith(fontSize: 13, color: AppColors.muted);
return Pressable(
pressedScale: 1,
pressedOpacity: .7,
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.xs, vertical: 13),
child: Row(
children: [
PetAvatar(pet: card.pet, size: 46),
const SizedBox(width: 13),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(card.pet.name, style: textTheme.bodyLarge!.copyWith(fontSize: 16, fontWeight: FontWeight.w700)),
const SizedBox(height: 2),
Text.rich(
TextSpan(children: [
TextSpan(
text: card.senderName,
style: const TextStyle(fontWeight: FontWeight.w700, color: AppColors.ink2),
),
const TextSpan(text: '님이 보냈어요.'),
]),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: sub,
),
],
),
),
const SizedBox(width: 13),
const RupIcon(RupIconName.chevR, size: 19, color: _kChevron),
],
),
),
);
}
}
/// 오른쪽 돋보기 버튼에서 왼쪽으로 펼쳐지는 검색창 (시안 .rup-searchx)
class _SearchBox extends StatelessWidget {
const _SearchBox({
@@ -312,42 +394,51 @@ class _SearchBox extends StatelessWidget {
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),
),
),
),
],
],
child: LayoutBuilder(
builder: (context, constraints) {
final width = math.max(constraints.maxWidth, open ? _kSearchOpenMinWidth : 0.0);
return OverflowBox(alignment: Alignment.centerLeft, minWidth: width, maxWidth: width, child: _row(context));
},
),
),
),
);
}
Widget _row(BuildContext context) {
return 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,219 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:tma_app/core/api/api_guard.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_card/domain/received_card.dart';
import 'package:tma_app/features/pet_card/presentation/pages/card_scanner_page.dart';
import 'package:tma_app/features/pet_card/presentation/providers/received_card_providers.dart';
import 'package:tma_app/features/pet_card/presentation/widgets/card_code_sheet.dart';
import 'package:tma_app/features/pet_card/presentation/widgets/name_card.dart';
import 'package:tma_app/features/pet_card/presentation/widgets/received_card_info.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/section_title.dart';
/// 명함 받기 — 스캐너(또는 코드 입력)로 명함을 찾아 저장 확인 화면을 연다
Future<void> startReceivingCard(BuildContext context, WidgetRef ref) async {
if (!ref.read(receivedCardsProvider.notifier).available) {
ref.read(toastProvider.notifier).show('기기 저장 모드에서는 명함을 받을 수 없어요', icon: RupIconName.qrcode);
return;
}
final outcome = await context.push<ScanOutcome>(AppRoutes.petCardScan);
if (!context.mounted) return;
final card = switch (outcome) {
ScanFound(:final card) => card,
ScanTypeCode() => await showCardCodeSheet(context),
null => null,
};
if (card == null || !context.mounted) return;
await context.push(AppRoutes.petCardReceive, extra: card);
}
/// 받은 명함 저장 확인 — 명함을 보고 메모를 남긴 뒤 보관함에 저장한다
class ReceiveCardPage extends ConsumerStatefulWidget {
const ReceiveCardPage({super.key, required this.card, this.today});
final SharedCard card;
/// 받은 날짜 기준 (테스트용). 없으면 오늘
final DateTime? today;
@override
ConsumerState<ReceiveCardPage> createState() => _ReceiveCardPageState();
}
class _ReceiveCardPageState extends ConsumerState<ReceiveCardPage> {
final _memo = TextEditingController();
bool _saving = false;
@override
void initState() {
super.initState();
// 글자 수 표시를 갱신한다
_memo.addListener(() => setState(() {}));
}
@override
void dispose() {
_memo.dispose();
super.dispose();
}
Future<void> _save() async {
if (_saving) return;
setState(() => _saving = true);
final note = _memo.text.trim();
final name = widget.card.pet.name;
final saved = await succeeded(
ref.read(receivedCardsProvider.notifier).receive(widget.card.code, note: note.isEmpty ? null : note),
);
if (!mounted) return;
if (!saved) {
setState(() => _saving = false);
return;
}
context.pop();
ref.read(toastProvider.notifier).show('$name 명함을 보관함에 저장했어요');
}
@override
Widget build(BuildContext context) {
final card = widget.card;
final textTheme = Theme.of(context).textTheme;
return Scaffold(
body: SafeArea(
bottom: false,
child: SingleChildScrollView(
padding: EdgeInsets.fromLTRB(
AppSpacing.xl,
AppSpacing.xs,
AppSpacing.xl,
MediaQuery.paddingOf(context).bottom + AppSpacing.xxl,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_Header(title: '${card.pet.name}의 명함', onBack: () => context.pop()),
NameCard(pet: card.pet, style: card.style, today: widget.today),
const DividerBand(top: 30),
ReceivedCardInfo(card: card, receivedAt: widget.today ?? DateTime.now()),
const SizedBox(height: 14),
Padding(
padding: const EdgeInsets.only(left: 2, bottom: AppSpacing.sm),
child: Row(
children: [
const RupIcon(RupIconName.edit, size: 15, stroke: 2.2, color: AppColors.ink),
const SizedBox(width: 6),
Text('내 메모', style: textTheme.bodyMedium!.copyWith(fontSize: 13.5, fontWeight: FontWeight.w700)),
],
),
),
_MemoField(controller: _memo),
Padding(
padding: const EdgeInsets.only(top: 5),
child: Text(
'${_memo.text.characters.length}/$kReceivedNoteMaxLength',
textAlign: TextAlign.right,
style: textTheme.bodySmall!.copyWith(fontSize: 11.5, color: AppColors.muted),
),
),
const SizedBox(height: AppSpacing.sm),
RupButton(label: '보관함에 저장', dimmed: _saving, onPressed: () => unawaited(_save())),
],
),
),
),
);
}
}
/// 가운데 제목 머리 — 아래 구분선 (시안 저장 확인 화면)
class _Header extends StatelessWidget {
const _Header({required this.title, required this.onBack});
final String title;
final VoidCallback onBack;
@override
Widget build(BuildContext context) {
return Container(
margin: const EdgeInsets.only(bottom: 18),
padding: const EdgeInsets.only(top: 2, bottom: AppSpacing.lg),
decoration: const BoxDecoration(border: Border(bottom: BorderSide(color: AppColors.line2))),
child: Row(
children: [
Semantics(
button: true,
label: '뒤로',
child: GestureDetector(
onTap: onBack,
behavior: HitTestBehavior.opaque,
child: const Padding(
padding: EdgeInsets.all(AppSpacing.xs),
child: RupIcon(RupIconName.chevL, color: AppColors.ink),
),
),
),
Expanded(
child: Text(
title,
textAlign: TextAlign.center,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontFamily: AppFonts.display,
fontSize: 17,
fontWeight: FontWeight.w600,
height: 1.2,
color: AppColors.ink,
),
),
),
const SizedBox(width: 30),
],
),
);
}
}
/// 받은 명함 메모칸 (시안 textarea — 바탕 surface-2, 모서리 14, 최소 높이 92)
class _MemoField extends StatelessWidget {
const _MemoField({required this.controller});
final TextEditingController controller;
@override
Widget build(BuildContext context) {
final style = Theme.of(context).textTheme.bodyLarge!.copyWith(fontSize: 14, height: 1.55, color: AppColors.ink);
return Container(
constraints: const BoxConstraints(minHeight: 92),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 13),
decoration: BoxDecoration(
color: AppColors.surface2,
borderRadius: BorderRadius.circular(14),
border: Border.all(color: AppColors.line),
),
child: TextField(
controller: controller,
keyboardType: TextInputType.multiline,
minLines: 3,
maxLines: null,
inputFormatters: [LengthLimitingTextInputFormatter(kReceivedNoteMaxLength)],
style: style,
cursorColor: AppColors.primary,
decoration: InputDecoration.collapsed(
hintText: '이 명함에 대해 기억할 내용을 적어두세요. (어디서 만났는지, 특징 등)',
hintStyle: style.copyWith(color: AppColors.muted),
),
),
);
}
}
@@ -0,0 +1,114 @@
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/api/api_guard.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/domain/card_view_mode.dart';
import 'package:tma_app/features/pet_card/domain/received_card.dart';
import 'package:tma_app/features/pet_card/presentation/providers/received_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/received_card_info.dart';
import 'package:tma_app/shared/widgets/icons/rup_icon.dart';
import 'package:tma_app/shared/widgets/rup_toast.dart';
import 'package:tma_app/shared/widgets/section_title.dart';
import 'package:tma_app/shared/widgets/sub_page_header.dart';
/// 보관함의 받은 명함 — 보낸 사람이 공유한 모양 그대로 보여 준다
class ReceivedCardDetailPage extends ConsumerWidget {
const ReceivedCardDetailPage({super.key, required this.receivedId});
final String receivedId;
Future<void> _delete(BuildContext context, WidgetRef ref, ReceivedCard card) async {
// 화면을 닫은 뒤에도 쓰도록 먼저 꺼내 둔다
final cards = ref.read(receivedCardsProvider.notifier);
final toast = ref.read(toastProvider.notifier);
// 먼저 화면을 닫아 지워진 명함을 그리지 않게 한다 (시안: 확인 없이 바로 삭제)
context.pop();
if (!await succeeded(cards.delete(card.id))) return;
toast.show('보관함에서 삭제했어요');
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final card = ref.watch(receivedCardByIdProvider(receivedId));
if (card == null) return const Scaffold(body: SizedBox.shrink());
final detail = card.mode == CardViewMode.detail;
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: card.pet.name,
subtitle: '받은 명함 · ${card.senderName}님',
displayFont: true,
bottomGap: 16,
onBack: () => context.pop(),
),
if (detail)
PetIdCard(pet: card.pet, style: card.style)
else
Padding(
padding: const EdgeInsets.only(top: 18),
child: NameCard(pet: card.pet, style: card.style),
),
DividerBand(top: detail ? 52 : 30),
ReceivedCardInfo(card: card, receivedAt: card.receivedAt, showNote: true, note: card.note),
const SizedBox(height: 52),
_DeleteButton(onTap: () => unawaited(_delete(context, ref, card))),
],
),
),
),
);
}
}
class _DeleteButton extends StatelessWidget {
const _DeleteButton({required this.onTap});
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return Semantics(
button: true,
child: GestureDetector(
onTap: onTap,
behavior: HitTestBehavior.opaque,
child: Padding(
padding: const EdgeInsets.all(AppSpacing.xs),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const RupIcon(RupIconName.x, size: 16, stroke: 2.2, color: AppColors.catHospital),
const SizedBox(width: 6),
Text(
'보관함에서 삭제',
style: Theme.of(context).textTheme.bodyMedium!.copyWith(
fontSize: 14,
fontWeight: FontWeight.w700,
color: AppColors.catHospital,
),
),
],
),
),
),
);
}
}
@@ -4,6 +4,7 @@ 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/card_view_mode.dart';
import 'package:tma_app/features/pet_card/domain/pet_card.dart';
/// 내가 만든 명함 + 아이별 대표 명함
@@ -66,6 +67,9 @@ class PetCardNotifier extends AsyncNotifier<PetCardState> {
));
}
/// 공유 시트를 열 때 — 받는 사람이 볼 모양을 서버에 알린다
Future<void> setShareMode(String cardId, CardViewMode mode) => _repo.setShareMode(cardId, mode);
Future<void> setRepresentative(PetCard card) async {
await _repo.setRepresentative(card.petId, card.id);
_emit(_current.copyWith(representatives: {..._current.representatives, card.petId: card.id}));
@@ -0,0 +1,47 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:tma_app/core/api/session_store.dart';
import 'package:tma_app/features/pet_card/data/received_card_repository.dart';
import 'package:tma_app/features/pet_card/domain/received_card.dart';
/// 보관함의 받은 명함 — 최근에 받은 명함이 앞
class ReceivedCardNotifier extends AsyncNotifier<List<ReceivedCard>> {
ReceivedCardRepository get _repo => ref.read(receivedCardRepositoryProvider);
@override
Future<List<ReceivedCard>> build() async {
// 회원이 바뀌면(로그인·로그아웃) 다시 읽는다
ref.watch(sessionEpochProvider);
return ref.watch(receivedCardRepositoryProvider).fetch();
}
List<ReceivedCard> get _current => state.value ?? const [];
void _emit(List<ReceivedCard> next) {
if (ref.mounted) state = AsyncData(next);
}
/// 명함 받기를 쓸 수 있는지 — 기기 저장 모드에서는 받을 명함이 없다
bool get available => _repo.available;
/// 받기 전 미리보기 (명함 코드로 조회)
Future<SharedCard> preview(String code) => _repo.preview(code);
Future<ReceivedCard> receive(String code, {String? note}) async {
final card = await _repo.receive(code, note: note);
_emit([card, ..._current]);
return card;
}
Future<void> delete(String id) async {
await _repo.delete(id);
_emit(_current.where((c) => c.id != id).toList());
}
}
final receivedCardsProvider = AsyncNotifierProvider<ReceivedCardNotifier, List<ReceivedCard>>(
ReceivedCardNotifier.new,
);
final receivedCardByIdProvider = Provider.family<ReceivedCard?, String>((ref, id) {
return ref.watch(receivedCardsProvider).value?.where((c) => c.id == id).firstOrNull;
});
@@ -0,0 +1,111 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:tma_app/core/api/api_guard.dart';
import 'package:tma_app/core/api/error_lexicon.dart';
import 'package:tma_app/core/constants/app_colors.dart';
import 'package:tma_app/core/theme/app_typography.dart';
import 'package:tma_app/features/pet_card/domain/received_card.dart';
import 'package:tma_app/features/pet_card/presentation/providers/received_card_providers.dart';
import 'package:tma_app/shared/widgets/icons/rup_icon.dart';
import 'package:tma_app/shared/widgets/rup_form_fields.dart';
import 'package:tma_app/shared/widgets/rup_sheet.dart';
import 'package:tma_app/shared/widgets/rup_toast.dart';
/// 명함 코드 직접 입력 — 찾은 명함을 돌려준다 (못 찾았거나 닫으면 null)
Future<SharedCard?> showCardCodeSheet(BuildContext context) {
return showRupSheet<SharedCard>(
context,
title: '명함코드 입력하기',
flushBottom: true,
builder: (context) => const _CodeBody(),
);
}
class _CodeBody extends ConsumerStatefulWidget {
const _CodeBody();
@override
ConsumerState<_CodeBody> createState() => _CodeBodyState();
}
class _CodeBodyState extends ConsumerState<_CodeBody> {
final _code = TextEditingController();
bool _busy = false;
@override
void initState() {
super.initState();
// 입력이 생기면 버튼을 켠다
_code.addListener(() => setState(() {}));
}
@override
void dispose() {
_code.dispose();
super.dispose();
}
Future<void> _submit() async {
if (_busy || _code.text.trim().isEmpty) return;
final code = parseCardCode(_code.text);
if (code == null) {
// 명함 코드 모양이 아니다 — 서버가 못 찾았을 때와 같은 안내
ref.read(toastProvider.notifier).show(ErrorLexicon.getMessage('CARD_002'), icon: RupIconName.alert);
return;
}
setState(() => _busy = true);
try {
final card = await ref.read(receivedCardsProvider.notifier).preview(code);
if (mounted) Navigator.of(context).pop(card);
} catch (e) {
if (!isReportedApiFailure(e)) rethrow;
if (mounted) setState(() => _busy = false);
}
}
@override
Widget build(BuildContext context) {
final textTheme = Theme.of(context).textTheme;
final ready = _code.text.trim().isNotEmpty && !_busy;
return SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.only(bottom: 18),
child: Text(
'아이 명함의 코드를 입력해주세요.\n명함 코드는 내 아이 명함 공유하기 버튼을 누르면 확인할 수 있어요.',
textAlign: TextAlign.center,
style: textTheme.bodySmall!.copyWith(fontSize: 12.5, color: AppColors.muted, height: 1.5),
),
),
RupField(
label: '명함 코드',
child: RupTextField(
controller: _code,
hintText: '예: 0428-117',
autofocus: true,
// 하이픈 없이 7자리만 넣어도 받는다 (parseCardCode)
keyboardType: TextInputType.number,
textInputAction: TextInputAction.done,
textAlign: TextAlign.center,
textStyle: const TextStyle(
fontFamily: AppFonts.display,
fontSize: 18,
letterSpacing: 1,
height: AppTypography.displayNormalHeight,
),
onSubmitted: (_) => unawaited(_submit()),
),
),
Opacity(
opacity: ready ? 1 : .45,
child: RupSheetBleedButton(label: '보관함에 저장', onPressed: () => unawaited(_submit())),
),
],
),
);
}
}
@@ -0,0 +1,171 @@
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/core/utils/date_format.dart';
import 'package:tma_app/features/pet_card/domain/received_card.dart';
import 'package:tma_app/shared/widgets/icons/rup_icon.dart';
import 'package:tma_app/shared/widgets/photo_image.dart';
// 시안 InfoRow 아이콘 색
const Color _kDateTint = Color(0xFFC084FC);
const Color _kNoteTint = Color(0xFF5BA8D8);
const double _kIconBox = 36;
/// 받은 명함의 "명함 정보" — 보낸 사람 · 받은 날짜 (+ 내 메모)
class ReceivedCardInfo extends StatelessWidget {
const ReceivedCardInfo({
super.key,
required this.card,
required this.receivedAt,
this.showNote = false,
this.note,
});
final SharedCard card;
final DateTime receivedAt;
/// 보관함의 받은 명함이면 내 메모 줄도 보여 준다 (없으면 "남긴 메모가 없어요.")
final bool showNote;
final String? note;
@override
Widget build(BuildContext context) {
final textTheme = Theme.of(context).textTheme;
final value = textTheme.bodyMedium!.copyWith(fontSize: 14.5, fontWeight: FontWeight.w700, color: AppColors.ink);
final memo = note;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(2, 0, 2, 6),
child: Text('명함 정보', style: textTheme.titleSmall!.copyWith(fontSize: 15, fontWeight: FontWeight.w700)),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.xs),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_InfoRow(
first: true,
tint: AppColors.primary,
icon: _SenderMark(card: card),
label: '보낸 사람',
child: Text.rich(
TextSpan(children: [
TextSpan(text: card.senderName),
const TextSpan(text: '님', style: TextStyle(fontWeight: FontWeight.w600, color: AppColors.ink2)),
]),
style: value,
),
),
_InfoRow(
tint: _kDateTint,
icon: const RupIcon(RupIconName.calendar, size: 18, stroke: 2, color: _kDateTint),
label: '받은 날짜',
child: Text(formatYmd(receivedAt), style: value),
),
if (showNote)
_InfoRow(
tint: _kNoteTint,
icon: const RupIcon(RupIconName.text, size: 18, stroke: 2, color: _kNoteTint),
label: '내 메모',
child: Text(
memo ?? '남긴 메모가 없어요.',
style: textTheme.bodyMedium!.copyWith(
fontSize: 13.5,
height: 1.6,
fontWeight: FontWeight.w500,
color: memo == null ? AppColors.muted : AppColors.ink2,
),
),
),
],
),
),
],
);
}
}
/// 시안 InfoRow — 색 상자 아이콘 + 라벨 + 값
class _InfoRow extends StatelessWidget {
const _InfoRow({
required this.tint,
required this.icon,
required this.label,
required this.child,
this.first = false,
});
final Color tint;
final Widget icon;
final String label;
final Widget child;
final bool first;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(vertical: 13),
decoration: BoxDecoration(
border: first ? null : const Border(top: BorderSide(color: AppColors.line2)),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: _kIconBox,
height: _kIconBox,
decoration: BoxDecoration(
// 시안 color-mix(tint 14%, transparent)
color: tint.withValues(alpha: .14),
borderRadius: BorderRadius.circular(11),
),
clipBehavior: Clip.antiAlias,
alignment: Alignment.center,
child: icon,
),
const SizedBox(width: AppSpacing.md),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: Theme.of(context).textTheme.bodySmall!.copyWith(fontSize: 12, color: AppColors.muted),
),
const SizedBox(height: 3),
child,
],
),
),
],
),
);
}
}
/// 보낸 사람 — 프로필 사진이 있으면 사진, 없으면 닉네임 첫 글자 (시안 SenderAvatar)
class _SenderMark extends StatelessWidget {
const _SenderMark({required this.card});
final SharedCard card;
@override
Widget build(BuildContext context) {
final initial = Text(
card.senderName.characters.take(1).toString(),
style: const TextStyle(
fontFamily: AppFonts.display,
fontSize: 15,
fontWeight: FontWeight.w800,
height: AppTypography.displayNormalHeight,
color: AppColors.primary,
),
);
final photo = card.senderPhotoPath;
if (photo == null) return initial;
return SizedBox.expand(child: PhotoImage(path: photo, fallback: Center(child: initial)));
}
}
@@ -8,6 +8,7 @@ import 'package:tma_app/core/constants/app_colors.dart';
import 'package:tma_app/core/constants/app_sizes.dart';
import 'package:tma_app/core/storage/album_saver.dart';
import 'package:tma_app/core/theme/app_shadows.dart';
import 'package:tma_app/features/pet_card/domain/received_card.dart';
import 'package:tma_app/shared/widgets/blurred_modal.dart';
import 'package:tma_app/shared/widgets/capture_boundary.dart';
import 'package:tma_app/shared/widgets/icons/rup_icon.dart';
@@ -16,9 +17,6 @@ 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;