feat(flutter): 마이페이지·나의 반려동물·반려동물 정보·설정 구현

시안은 아이를 등록하면 "나의 반려동물" 목록으로 보내므로 명함 다음에 만든다.
- 마이페이지: 프로필(닉네임·사진 편집), 반려동물·명함 바로가기, 권한·알림 스위치
  (스위치는 앱 설정값만 저장한다 — OS 권한·푸시는 해당 기능에서 요청)
- 나의 반려동물 / 반려동물 정보(읽기 전용, 연필로 수정) — 아이마다 고유색을 배정한다
- 설정: 약관 문서, 계정 변경·로그아웃, 회원탈퇴(기기 기록 삭제 후 인트로로)
- 등록·삭제 후와 홈 "아이 상세보기"는 나의 반려동물 목록으로 간다

시안 대조 중 발견한 공통 문제도 고쳤다.
- 줄 높이를 주지 않은 글자가 본문 기본값(1.6)을 물려받아 시안보다 줄이 컸다
  → AppTypography.normalHeight 를 두고 섹션 제목·시트 제목에 적용 (홈 간격 보정 포함)
- 버튼이 옆 제목 글자와 한 의미 단위로 합쳐져 스크린리더가 함께 읽었다
  → Pressable·설정 줄을 별도 의미 노드로

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-28 21:50:10 +09:00
parent 46bb77df9b
commit 1dba0c80ab
36 changed files with 2049 additions and 21 deletions
@@ -13,6 +13,9 @@ abstract interface class AuthRepository {
/// 인사말에 쓰는 이름 — 소셜 계정 프로필에서 받는다. 모르면 null
String? get nickname;
/// 로그인한 소셜 계정 종류 — 로그인 전이면 null
SocialProvider? get provider;
Future<void> signIn(SocialProvider provider);
Future<void> signOut();
@@ -34,6 +37,10 @@ class DemoAuthRepository implements AuthRepository {
@override
String? get nickname => null;
@override
SocialProvider? get provider =>
SocialProvider.values.asNameMap()[_prefs.getString(_kDemoSessionKey)];
@override
Future<void> signIn(SocialProvider provider) async {
await _prefs.setString(_kDemoSessionKey, provider.name);
@@ -1,2 +1,12 @@
/// 지원하는 소셜 로그인 제공자
enum SocialProvider { naver, kakao, google, apple }
enum SocialProvider {
naver('네이버'),
kakao('카카오'),
google('구글'),
apple('Apple');
const SocialProvider(this.label);
/// 화면 표기 — "카카오 계정으로 로그인했어요"
final String label;
}
@@ -1,12 +1,22 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:tma_app/features/auth/data/repositories/auth_repository.dart';
import 'package:tma_app/features/auth/domain/social_provider.dart';
import 'package:tma_app/features/entry/presentation/providers/entry_provider.dart';
import 'package:tma_app/features/mypage/data/profile_repository.dart';
/// 이름을 모를 때 부르는 호칭 — 로그인 화면의 "반가워요, 집사님!" 과 맞춘다
const String kDefaultNickname = '집사';
/// 인사말에 쓰는 사용자 이름 (로그인·로그아웃하면 다시 읽는다)
/// 인사말에 쓰는 사용자 이름 — 앱에서 바꾼 닉네임 > 소셜 계정 이름 > 기본 호칭
final userNicknameProvider = Provider<String>((ref) {
ref.watch(entryProvider.select((s) => s.signedIn));
return ref.watch(authRepositoryProvider).nickname ?? kDefaultNickname;
return ref.watch(profileProvider).nickname ??
ref.watch(authRepositoryProvider).nickname ??
kDefaultNickname;
});
/// 로그인한 소셜 계정 종류
final signedInProviderProvider = Provider<SocialProvider?>((ref) {
ref.watch(entryProvider.select((s) => s.signedIn));
return ref.watch(authRepositoryProvider).provider;
});
@@ -47,7 +47,7 @@ class HomePage extends ConsumerWidget {
title: '우리 아이',
sub: true,
trailing: '아이 상세보기',
onTrailingTap: () => context.go(AppRoutes.myPage),
onTrailingTap: () => context.go(AppRoutes.myPets),
),
),
),
@@ -19,11 +19,13 @@ class HomeScheduleSection extends StatelessWidget {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// 시안: 이 섹션 제목만 기본 위 여백(26)이 살아 있어 다른 섹션보다 조금 아래에 있다
const SizedBox(height: AppSpacing.xs),
Padding(
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.xs),
child: SectionTitle(title: '일정', trailing: '전체보기', onTrailingTap: onSeeAll),
),
const SizedBox(height: 17),
const SizedBox(height: 21),
Padding(
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.xs),
child: SectionTitle(
@@ -0,0 +1,37 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:tma_app/core/storage/preferences_provider.dart';
/// 마이페이지 설정 스위치 — 앱 안의 선호값이다.
/// (카메라·갤러리 권한 자체는 OS 가 관리하므로, 실제 권한 요청은 해당 기능을 쓸 때 한다)
enum AppSetting {
camera('settings.camera', true),
gallery('settings.gallery', true),
scheduleNotification('settings.notiSchedule', true),
eventNotification('settings.notiEvent', false),
batteryOptimizationExcluded('settings.batteryOpt', false);
const AppSetting(this.key, this.defaultValue);
final String key;
final bool defaultValue;
}
class AppSettingsNotifier extends Notifier<Map<AppSetting, bool>> {
SharedPreferences get _prefs => ref.read(sharedPreferencesProvider);
@override
Map<AppSetting, bool> build() {
final prefs = ref.watch(sharedPreferencesProvider);
return {for (final s in AppSetting.values) s: prefs.getBool(s.key) ?? s.defaultValue};
}
Future<void> set(AppSetting setting, bool value) async {
state = {...state, setting: value};
await _prefs.setBool(setting.key, value);
}
}
final appSettingsProvider = NotifierProvider<AppSettingsNotifier, Map<AppSetting, bool>>(
AppSettingsNotifier.new,
);
@@ -0,0 +1,54 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:tma_app/core/storage/preferences_provider.dart';
const String _kNicknameKey = 'profile.nickname';
const String _kPhotoKey = 'profile.photo';
/// 내 프로필 — 앱에서 바꾼 닉네임·사진 (없으면 소셜 계정 값 / 기본값을 쓴다)
class UserProfile {
const UserProfile({this.nickname, this.photoPath});
final String? nickname;
final String? photoPath;
}
/// 프로필 저장 — 서버 회원 API 가 생기면 그쪽으로 옮긴다
class ProfileRepository {
ProfileRepository(this._prefs);
final SharedPreferences _prefs;
UserProfile load() => UserProfile(
nickname: _prefs.getString(_kNicknameKey),
photoPath: _prefs.getString(_kPhotoKey),
);
Future<void> save(UserProfile profile) async {
final nickname = profile.nickname;
final photo = profile.photoPath;
nickname == null ? await _prefs.remove(_kNicknameKey) : await _prefs.setString(_kNicknameKey, nickname);
photo == null ? await _prefs.remove(_kPhotoKey) : await _prefs.setString(_kPhotoKey, photo);
}
}
final profileRepositoryProvider = Provider<ProfileRepository>((ref) {
return ProfileRepository(ref.watch(sharedPreferencesProvider));
});
class ProfileNotifier extends Notifier<UserProfile> {
@override
UserProfile build() => ref.watch(profileRepositoryProvider).load();
/// 넘긴 값만 바꾼다 (null 이면 기존 값 유지)
Future<void> update({String? nickname, String? photoPath}) async {
final next = UserProfile(
nickname: nickname ?? state.nickname,
photoPath: photoPath ?? state.photoPath,
);
await ref.read(profileRepositoryProvider).save(next);
state = next;
}
}
final profileProvider = NotifierProvider<ProfileNotifier, UserProfile>(ProfileNotifier.new);
@@ -0,0 +1,46 @@
/// 약관 문서의 한 조항
class PolicySection {
const PolicySection(this.heading, this.body);
final String heading;
final String body;
}
/// 설정 > 약관 및 정책 문서.
/// 시안 문구를 그대로 옮긴 초안이다 — 정식 문서가 확정되면 서버/웹 문서로 교체한다.
enum PolicyDoc {
privacy(
title: '개인정보 처리방침',
effective: '2026년 1월 1일 시행',
sections: [
PolicySection('1. 수집하는 개인정보 항목', '이메일 주소, 반려동물 정보(이름·품종·생년월일·건강 기록), 기기 정보 및 알림 토큰을 수집합니다.'),
PolicySection('2. 개인정보의 이용 목적', '일정 알림 제공, 펫 명함 생성·공유, 서비스 개선 및 고객 문의 응대를 위해 이용합니다.'),
PolicySection('3. 보유 및 이용 기간', '회원 탈퇴 시 지체 없이 파기하며, 관계 법령에 따라 보관이 필요한 경우 해당 기간 동안 안전하게 보관합니다.'),
PolicySection('4. 제3자 제공', 'rup은 이용자의 동의 없이 개인정보를 외부에 제공하지 않습니다.'),
PolicySection('5. 이용자의 권리', '이용자는 언제든지 개인정보 열람·정정·삭제 및 처리정지를 요청할 수 있습니다.'),
],
),
terms(
title: '이용약관',
effective: '2026년 1월 1일 시행',
sections: [
PolicySection('제1조 (목적)', '본 약관은 rup이 제공하는 반려동물 일정관리 서비스의 이용 조건 및 절차를 규정합니다.'),
PolicySection('제2조 (서비스 내용)', '일정·알림 관리, 펫 명함 생성·공유, 다이어리 기록 기능을 제공합니다.'),
PolicySection('제3조 (이용자의 의무)', '이용자는 타인의 정보를 도용하거나 서비스의 정상적인 운영을 방해하는 행위를 해서는 안 됩니다.'),
PolicySection('제4조 (콘텐츠의 권리)', '이용자가 작성한 다이어리·사진의 저작권은 이용자에게 있으며, rup은 서비스 제공 목적 범위 내에서만 이를 이용합니다.'),
PolicySection('제5조 (책임의 제한)', 'rup은 천재지변 또는 이용자의 귀책사유로 발생한 서비스 장애에 대해 책임을 지지 않습니다.'),
],
);
const PolicyDoc({required this.title, required this.effective, required this.sections});
final String title;
/// 시행일 문구
final String effective;
final List<PolicySection> sections;
/// 경로의 문서 이름 → 문서 (모르는 이름이면 null)
static PolicyDoc? byName(String? name) =>
PolicyDoc.values.where((d) => d.name == name).firstOrNull;
}
@@ -0,0 +1,281 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:go_router/go_router.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_info.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/auth/presentation/providers/user_provider.dart';
import 'package:tma_app/features/mypage/data/app_settings_repository.dart';
import 'package:tma_app/features/mypage/data/profile_repository.dart';
import 'package:tma_app/features/mypage/presentation/widgets/profile_edit_sheet.dart';
import 'package:tma_app/features/mypage/presentation/widgets/settings_row.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/presentation/providers/pet_card_providers.dart';
import 'package:tma_app/shared/widgets/icons/rup_icon.dart';
import 'package:tma_app/shared/widgets/person_avatar.dart';
import 'package:tma_app/shared/widgets/pressable.dart';
import 'package:tma_app/shared/widgets/rup_button.dart';
import 'package:tma_app/shared/widgets/rup_card.dart';
import 'package:tma_app/shared/widgets/rup_logo.dart';
import 'package:tma_app/shared/widgets/rup_toast.dart';
import 'package:tma_app/shared/widgets/section_title.dart';
const double _kNormal = AppTypography.normalHeight;
/// 탭바 뒤로 콘텐츠가 가려지지 않게 두는 여유
const double _kBottomGap = 30;
/// 스위치를 바꿨을 때 보여 줄 안내 (켰을 때, 껐을 때)와 아이콘
typedef _ToggleCopy = ({String on, String off, RupIconName icon});
const Map<AppSetting, _ToggleCopy> _kToggleCopy = {
AppSetting.camera: (on: '카메라 권한을 허용했어요', off: '카메라 권한을 껐어요', icon: RupIconName.camera),
AppSetting.gallery: (on: '갤러리 저장을 허용했어요', off: '갤러리 저장을 껐어요', icon: RupIconName.download),
AppSetting.scheduleNotification: (on: '일정·다이어리 알림을 켰어요', off: '일정·다이어리 알림을 껐어요', icon: RupIconName.bell),
AppSetting.eventNotification: (on: '혜택·이벤트 알림을 켰어요', off: '혜택·이벤트 알림을 껐어요', icon: RupIconName.sparkle),
AppSetting.batteryOptimizationExcluded: (on: '배터리 최적화에서 제외했어요', off: '배터리 최적화에 포함했어요', icon: RupIconName.battery),
};
/// 마이페이지 탭 — 내 프로필, 반려동물·명함 바로가기, 앱 설정
class MyPage extends ConsumerWidget {
const MyPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final settings = ref.watch(appSettingsProvider);
Future<void> toggle(AppSetting setting, bool value) async {
await ref.read(appSettingsProvider.notifier).set(setting, value);
final copy = _kToggleCopy[setting]!;
ref.read(toastProvider.notifier).show(value ? copy.on : copy.off, icon: copy.icon);
}
SettingsRow row(AppSetting setting, String title, RupIconName icon, Color tint, {bool first = false}) =>
SettingsRow(
icon: icon,
tint: tint,
title: title,
first: first,
toggleValue: settings[setting],
onToggle: (v) => unawaited(toggle(setting, v)),
);
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.xs,
bottom: MediaQuery.paddingOf(context).bottom + _kBottomGap,
),
children: [
padded(_Header(onSettings: () => unawaited(context.push(AppRoutes.settings)))),
const SizedBox(height: 18),
padded(const _ProfileCard()),
const DividerBand(),
padded(
Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Padding(
padding: EdgeInsets.symmetric(horizontal: AppSpacing.xs),
child: SectionTitle(title: '설정'),
),
const SettingsGroupLabel('앱 권한 설정', top: 14),
RupCard(
child: Column(
children: [
row(AppSetting.camera, '카메라 접근 권한', RupIconName.camera, SettingsTints.camera, first: true),
row(AppSetting.gallery, '갤러리 저장 허용', RupIconName.download, SettingsTints.gallery),
],
),
),
const SettingsGroupLabel('알림 설정'),
RupCard(
child: Column(
children: [
row(AppSetting.scheduleNotification, '일정·다이어리 알림', RupIconName.bell, SettingsTints.schedule,
first: true),
row(AppSetting.eventNotification, '혜택 및 이벤트 알림', RupIconName.sparkle, SettingsTints.event),
row(AppSetting.batteryOptimizationExcluded, '배터리 최적화 제외', RupIconName.battery,
SettingsTints.battery),
],
),
),
const SizedBox(height: 56),
const Center(child: RupLogo(height: 22)),
const SizedBox(height: 2),
Text(
'버전 $kAppVersion',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodySmall!.copyWith(fontSize: 11, color: AppColors.muted, height: _kNormal),
),
],
),
),
],
),
);
}
}
class _Header extends StatelessWidget {
const _Header({required this.onSettings});
final VoidCallback onSettings;
@override
Widget build(BuildContext context) {
return Row(
children: [
Expanded(child: Text('마이페이지', style: Theme.of(context).textTheme.headlineSmall)),
Pressable(
onTap: onSettings,
pressedScale: .9,
semanticLabel: '설정',
child: const SizedBox.square(
dimension: 42,
child: Center(child: RupGlyphIcon(RupGlyph.gear, size: 23, color: AppColors.ink2)),
),
),
],
);
}
}
/// 내 프로필 + 반려동물·명함 관리 바로가기 — 한 상자
class _ProfileCard extends ConsumerWidget {
const _ProfileCard();
@override
Widget build(BuildContext context, WidgetRef ref) {
final nickname = ref.watch(userNicknameProvider);
final photo = ref.watch(profileProvider).photoPath;
final provider = ref.watch(signedInProviderProvider);
final petCount = (ref.watch(petListProvider).value ?? const <Pet>[]).length;
final cardCount = ref.watch(myCardsProvider).length;
final textTheme = Theme.of(context).textTheme;
return RupCard(
child: Column(
children: [
Padding(
padding: const EdgeInsets.all(18),
child: Row(
children: [
PersonAvatar(size: 58, photoPath: photo),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
nickname,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: textTheme.titleSmall!.copyWith(fontSize: 18, fontWeight: FontWeight.w800, height: _kNormal),
),
if (provider != null) ...[
const SizedBox(height: 2),
Text(
'${provider.label} 계정으로 로그인했어요',
style: textTheme.bodySmall!.copyWith(fontSize: 13, color: AppColors.muted, height: _kNormal),
),
],
],
),
),
RupSoftPillButton(
label: '편집',
showPlus: false,
background: AppColors.primary50,
onPressed: () => unawaited(showProfileEditSheet(context)),
),
],
),
),
DecoratedBox(
decoration: const BoxDecoration(border: Border(top: BorderSide(color: AppColors.line2))),
child: IntrinsicHeight(
child: Row(
children: [
Expanded(
child: _StatTile(
art: SvgPicture.asset(AppAssets.artRup, width: 40, height: 40),
title: '반려동물',
value: '$petCount마리',
onTap: () => context.go(AppRoutes.myPets),
),
),
const VerticalDivider(width: 1, thickness: 1, color: AppColors.line),
Expanded(
child: _StatTile(
art: SvgPicture.asset(AppAssets.artCard, width: 34, height: 34),
title: '명함 관리',
value: '$cardCount장',
onTap: () => unawaited(context.push(AppRoutes.petCardManage)),
),
),
],
),
),
),
],
),
);
}
}
class _StatTile extends StatelessWidget {
const _StatTile({required this.art, required this.title, required this.value, required this.onTap});
final Widget art;
final String title;
final String value;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final textTheme = Theme.of(context).textTheme;
return Semantics(
container: true,
button: true,
label: '$title $value',
excludeSemantics: true,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: AppSpacing.lg),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
art,
const SizedBox(width: AppSpacing.lg),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: textTheme.titleSmall!.copyWith(fontSize: 16.5, fontWeight: FontWeight.w700, height: 1.2),
),
const SizedBox(height: 2),
Text(value, style: textTheme.bodySmall!.copyWith(fontSize: 12.5, color: AppColors.muted, height: _kNormal)),
],
),
],
),
),
),
);
}
}
@@ -0,0 +1,209 @@
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/domain/pet_age.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/shared/widgets/icons/rup_icon.dart';
import 'package:tma_app/shared/widgets/rup_button.dart';
import 'package:tma_app/shared/widgets/rup_card.dart';
import 'package:tma_app/shared/widgets/section_title.dart';
import 'package:tma_app/shared/widgets/sub_page_header.dart';
import 'package:tma_app/shared/widgets/tab_sub_page.dart';
/// 칩 글자색을 만들 때 섞는 짙은 색 (시안 #2A2622)
const Color _kTintInk = Color(0xFF2A2622);
const double _kEdgeWidth = 4;
const double _kNormal = AppTypography.normalHeight;
/// 나의 반려동물 — 등록한 아이 목록, 누르면 반려동물 정보
class MyPetsPage extends ConsumerWidget {
const MyPetsPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final pets = ref.watch(petListProvider).value ?? const <Pet>[];
final today = DateTime.now();
return TabSubPage(
children: [
ScreenPadding(
child: SubPageHeader(
title: '나의 반려동물',
subtitle: '${pets.length}마리와 함께해요',
displayFont: true,
onBack: () => context.pop(),
trailing: RupSoftPillButton(
label: '아이 등록하기',
onPressed: () => unawaited(context.push(AppRoutes.petRegister)),
),
),
),
if (pets.isEmpty)
const ScreenPadding(child: RupCard(child: EmptyMessage('등록된 반려동물이 없어요', padding: 30)))
else
for (final (i, pet) in pets.indexed) ...[
if (i > 0) const SizedBox(height: AppSpacing.md),
ScreenPadding(
child: _PetRow(
pet: pet,
today: today,
onTap: () => context.go(AppRoutes.petInfo(pet.id)),
),
),
],
],
);
}
}
class _PetRow extends StatelessWidget {
const _PetRow({required this.pet, required this.today, required this.onTap});
final Pet pet;
final DateTime today;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final textTheme = Theme.of(context).textTheme;
final species = pet.breed.isEmpty ? pet.species : '${pet.species} · ${pet.breed}';
final chips = [petAgeLabel(pet.birthDate, today), pet.weightLabel, ?pet.bloodType];
return Semantics(
container: true,
button: true,
child: RupCard(
onTap: onTap,
child: Stack(
children: [
// 아이 고유색 띠
Positioned(
left: 0,
top: 0,
bottom: 0,
width: _kEdgeWidth,
child: ColoredBox(color: pet.tint),
),
Padding(
padding: const EdgeInsets.all(18),
child: Row(
children: [
PetAvatar(pet: pet, size: 58, ring: true),
const SizedBox(width: 15),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Flexible(
child: Text(
pet.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: textTheme.titleSmall!.copyWith(
fontSize: 17,
fontWeight: FontWeight.w800,
height: 1.1,
),
),
),
const SizedBox(width: 7),
_GenderPill(pet: pet),
],
),
const SizedBox(height: AppSpacing.xs),
Text(
species,
style: textTheme.bodySmall!.copyWith(fontSize: 12.5, color: AppColors.muted, height: _kNormal),
),
const SizedBox(height: 9),
Wrap(
spacing: 6,
runSpacing: 6,
children: [for (final c in chips) _TintChip(label: c, tint: pet.tint)],
),
],
),
),
const RupIcon(RupIconName.chevR, size: 17, color: AppColors.muted),
],
),
),
],
),
),
);
}
}
/// 성별 알약 — 아이 색을 옅게 깐 바탕
class _GenderPill extends StatelessWidget {
const _GenderPill({required this.pet});
final Pet pet;
@override
Widget build(BuildContext context) {
final fg = Color.lerp(pet.tint, _kTintInk, .30)!;
return Container(
padding: const EdgeInsets.fromLTRB(5, 2, 7, 2),
decoration: BoxDecoration(
color: Color.lerp(pet.tint, Colors.white, .82),
borderRadius: BorderRadius.circular(AppRadius.pill),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
RupIcon(pet.gender == PetGender.male ? RupIconName.male : RupIconName.female, size: 11, stroke: 2.4, color: fg),
const SizedBox(width: 3),
Text(
pet.gender.label,
style: Theme.of(context).textTheme.bodySmall!.copyWith(
fontSize: 11,
fontWeight: FontWeight.w700,
color: fg,
height: _kNormal,
),
),
],
),
);
}
}
/// 나이·몸무게·혈액형 칩
class _TintChip extends StatelessWidget {
const _TintChip({required this.label, required this.tint});
final String label;
final Color tint;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 3.5),
decoration: BoxDecoration(
color: Color.lerp(tint, Colors.white, .86),
borderRadius: BorderRadius.circular(AppSpacing.sm),
),
child: Text(
label,
style: Theme.of(context).textTheme.bodySmall!.copyWith(
fontSize: 11.5,
fontWeight: FontWeight.w700,
color: Color.lerp(tint, _kTintInk, .32),
height: _kNormal,
),
),
);
}
}
@@ -0,0 +1,54 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:tma_app/core/constants/app_colors.dart';
import 'package:tma_app/core/theme/app_typography.dart';
import 'package:tma_app/features/mypage/domain/policy_doc.dart';
import 'package:tma_app/shared/widgets/rup_card.dart';
import 'package:tma_app/shared/widgets/sub_page_header.dart';
import 'package:tma_app/shared/widgets/tab_sub_page.dart';
/// 약관 및 정책 문서 (개인정보 처리방침 · 이용약관)
class PolicyDocPage extends StatelessWidget {
const PolicyDocPage({super.key, required this.doc});
final PolicyDoc doc;
@override
Widget build(BuildContext context) {
final textTheme = Theme.of(context).textTheme;
return TabSubPage(
children: [
ScreenPadding(
child: SubPageHeader(title: doc.title, subtitle: doc.effective, onBack: () => context.pop()),
),
ScreenPadding(
child: RupCard(
padding: const EdgeInsets.all(18),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
for (final (i, s) in doc.sections.indexed) ...[
if (i > 0) const SizedBox(height: 18),
Text(
s.heading,
style: textTheme.titleSmall!.copyWith(
fontSize: 14.5,
fontWeight: FontWeight.w700,
color: AppColors.ink,
height: AppTypography.normalHeight,
),
),
const SizedBox(height: 6),
Text(
s.body,
style: textTheme.bodySmall!.copyWith(fontSize: 13, height: 1.7, color: AppColors.ink2),
),
],
],
),
),
),
],
);
}
}
@@ -0,0 +1,174 @@
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/auth/presentation/providers/user_provider.dart';
import 'package:tma_app/features/mypage/data/profile_repository.dart';
import 'package:tma_app/features/mypage/domain/policy_doc.dart';
import 'package:tma_app/features/mypage/presentation/providers/account_actions.dart';
import 'package:tma_app/features/mypage/presentation/widgets/settings_row.dart';
import 'package:tma_app/shared/widgets/icons/rup_icon.dart';
import 'package:tma_app/shared/widgets/person_avatar.dart';
import 'package:tma_app/shared/widgets/rup_card.dart';
import 'package:tma_app/shared/widgets/rup_dialog.dart';
import 'package:tma_app/shared/widgets/sub_page_header.dart';
import 'package:tma_app/shared/widgets/tab_sub_page.dart';
const double _kNormal = AppTypography.normalHeight;
/// 회원탈퇴 확인창 아이콘 색 (시안 #E05A4A)
const Color _kWithdrawTone = Color(0xFFE05A4A);
/// 설정 — 현재 계정, 약관 및 정책, 계정 변경·로그아웃·회원탈퇴
class SettingsPage extends ConsumerWidget {
const SettingsPage({super.key});
Future<void> _logout(BuildContext context, WidgetRef ref) async {
final ok = await showRupConfirm(
context,
title: '로그아웃할까요?',
message: '기록은 그대로 남아 있어요.\n다시 로그인하면 이어서 볼 수 있어요.',
confirmLabel: '로그아웃',
layout: RupConfirmLayout.row,
icon: RupIconName.logout,
iconBackground: AppColors.muted.withValues(alpha: .15),
);
if (ok) await ref.read(accountActionsProvider).logout();
}
Future<void> _withdraw(BuildContext context, WidgetRef ref) async {
final ok = await showRupConfirm(
context,
title: '정말 탈퇴할까요?',
message: '아이들의 일정·다이어리·펫 명함이 모두 삭제되고\n되돌릴 수 없어요.',
confirmLabel: '탈퇴하기',
layout: RupConfirmLayout.row,
icon: RupIconName.alert,
iconColor: _kWithdrawTone,
);
if (ok) await ref.read(accountActionsProvider).withdraw();
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final nickname = ref.watch(userNicknameProvider);
final photo = ref.watch(profileProvider).photoPath;
final provider = ref.watch(signedInProviderProvider);
final textTheme = Theme.of(context).textTheme;
return TabSubPage(
children: [
ScreenPadding(
child: SubPageHeader(title: '설정', subtitle: '계정과 약관을 관리해요', onBack: () => context.pop()),
),
ScreenPadding(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
RupCard(
padding: const EdgeInsets.all(AppSpacing.lg),
child: Row(
children: [
PersonAvatar(size: 46, photoPath: photo),
const SizedBox(width: 13),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
nickname,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: textTheme.titleSmall!.copyWith(fontSize: 15.5, fontWeight: FontWeight.w800, height: _kNormal),
),
if (provider != null) ...[
const SizedBox(height: 2),
Text(
'${provider.label} 계정으로 로그인했어요',
style: textTheme.bodySmall!.copyWith(fontSize: 12.5, color: AppColors.muted, height: _kNormal),
),
],
],
),
),
],
),
),
const SettingsGroupLabel('약관 및 정책'),
RupCard(
child: Column(
children: [
SettingsRow(
icon: RupIconName.shield,
tint: SettingsTints.privacy,
title: PolicyDoc.privacy.title,
first: true,
onTap: () => context.go(AppRoutes.policy(PolicyDoc.privacy)),
),
SettingsRow(
icon: RupIconName.text,
tint: SettingsTints.neutral,
title: PolicyDoc.terms.title,
onTap: () => context.go(AppRoutes.policy(PolicyDoc.terms)),
),
],
),
),
const SettingsGroupLabel('계정'),
RupCard(
child: Column(
children: [
// 계정 변경 = 로그아웃 후 다른 계정으로 로그인 (시안도 확인 없이 바로 나간다)
SettingsRow(
icon: RupIconName.users,
tint: SettingsTints.account,
title: '계정 변경',
detail: '다른 계정으로 로그인',
first: true,
onTap: () => unawaited(ref.read(accountActionsProvider).logout()),
),
SettingsRow(
icon: RupIconName.logout,
tint: SettingsTints.neutral,
title: '로그아웃',
onTap: () => unawaited(_logout(context, ref)),
),
],
),
),
const SizedBox(height: 34),
Semantics(
container: true,
button: true,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => unawaited(_withdraw(context, ref)),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: AppSpacing.sm),
child: Text(
'회원탈퇴',
textAlign: TextAlign.center,
style: textTheme.bodyMedium!.copyWith(
fontSize: 13.5,
fontWeight: FontWeight.w700,
color: AppColors.muted,
decoration: TextDecoration.underline,
decorationColor: AppColors.muted,
height: _kNormal,
),
),
),
),
),
],
),
),
],
);
}
}
@@ -0,0 +1,42 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:tma_app/core/storage/preferences_provider.dart';
import 'package:tma_app/features/entry/presentation/providers/entry_provider.dart';
import 'package:tma_app/features/mypage/data/app_settings_repository.dart';
import 'package:tma_app/features/mypage/data/profile_repository.dart';
import 'package:tma_app/features/pet/data/pet_photo_store.dart';
import 'package:tma_app/features/pet/presentation/providers/pet_providers.dart';
import 'package:tma_app/features/pet_card/presentation/providers/pet_card_providers.dart';
/// 계정 동작 — 로그아웃 / 회원탈퇴
class AccountActions {
AccountActions(this._ref);
final Ref _ref;
/// 로그아웃 — 기록은 기기에 남는다. 라우터가 로그인 화면으로 보낸다
Future<void> logout() => _ref.read(entryProvider.notifier).signOut();
/// 회원탈퇴 — 이 기기에 저장한 기록(아이·명함·사진·설정)을 모두 지우고 처음(인트로)으로 돌아간다.
/// 서버 회원 API 가 생기면 탈퇴 요청을 먼저 보낸다.
Future<void> withdraw() async {
// 기록 삭제와 화면 전환이 먼저다 — 사진 폴더 정리가 늦거나 실패해도 탈퇴는 끝나야 한다
final photos = _ref.read(petPhotoStoreProvider);
await _ref.read(sharedPreferencesProvider).clear();
// 캐시된 상태를 버리고 빈 저장소에서 다시 읽게 한다 (진입 상태가 바뀌면 라우터가 인트로로 보낸다)
_ref
..invalidate(petListProvider)
..invalidate(selectedPetIdProvider)
..invalidate(petCardsProvider)
..invalidate(userBackgroundsProvider)
..invalidate(profileProvider)
..invalidate(appSettingsProvider)
..invalidate(entryProvider);
try {
await photos.clearAll();
} on Exception {
// 남은 사진 파일은 가리키는 기록이 없어 다시 보이지 않고, 앱을 지우면 함께 지워진다
}
}
}
final accountActionsProvider = Provider<AccountActions>(AccountActions.new);
@@ -0,0 +1,147 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:image_picker/image_picker.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_typography.dart';
import 'package:tma_app/features/auth/presentation/providers/user_provider.dart';
import 'package:tma_app/features/mypage/data/profile_repository.dart';
import 'package:tma_app/features/pet/data/pet_photo_store.dart';
import 'package:tma_app/shared/widgets/icons/rup_icon.dart';
import 'package:tma_app/shared/widgets/person_avatar.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';
const double _kPhotoSize = 96;
const int _kNicknameMaxLength = 12;
/// 프로필 사진은 작게 보이므로 더 줄여 저장한다
const double _kPhotoMaxSide = 800;
const int _kPhotoQuality = 85;
/// 프로필 편집 시트 — 닉네임 · 사진
Future<void> showProfileEditSheet(BuildContext context) {
return showRupSheet<void>(
context,
title: '프로필 편집',
flushBottom: true,
builder: (context) => const _ProfileEditSheet(),
);
}
class _ProfileEditSheet extends ConsumerStatefulWidget {
const _ProfileEditSheet();
@override
ConsumerState<_ProfileEditSheet> createState() => _ProfileEditSheetState();
}
class _ProfileEditSheetState extends ConsumerState<_ProfileEditSheet> {
late final TextEditingController _name = TextEditingController(text: ref.read(userNicknameProvider));
late String? _photoPath = ref.read(profileProvider).photoPath;
@override
void dispose() {
_name.dispose();
super.dispose();
}
Future<void> _pickPhoto() async {
final file = await ImagePicker().pickImage(
source: ImageSource.gallery,
maxWidth: _kPhotoMaxSide,
maxHeight: _kPhotoMaxSide,
imageQuality: _kPhotoQuality,
);
if (file == null) return;
final path = await ref.read(petPhotoStoreProvider).persist(file);
if (!mounted) return;
setState(() => _photoPath = path);
}
Future<void> _submit() async {
final name = _name.text.trim();
// 비워 두면 닉네임은 바꾸지 않는다 (시안: 값이 있을 때만 반영)
await ref.read(profileProvider.notifier).update(
nickname: name.isEmpty ? null : name,
photoPath: _photoPath,
);
ref.read(toastProvider.notifier).show('프로필을 수정했어요', icon: RupIconName.check);
if (mounted) Navigator.of(context).pop();
}
@override
Widget build(BuildContext context) {
final photo = _photoPath;
return SingleChildScrollView(
// 바닥 버튼이 좌우 여백 밖까지 그려지도록 자르지 않는다
clipBehavior: Clip.none,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// 시안: 제목 아래 여백(14)과 사진 위 여백(10)은 겹쳐서 14 만 남는다
Center(
child: Semantics(
button: true,
label: photo == null ? '프로필 사진 추가하기' : '프로필 사진 바꾸기',
excludeSemantics: true,
child: GestureDetector(
onTap: () => unawaited(_pickPhoto()),
child: photo == null
? Container(
width: _kPhotoSize,
height: _kPhotoSize,
decoration: const BoxDecoration(color: AppColors.sand, shape: BoxShape.circle),
alignment: Alignment.center,
child: const _AddPhotoIcon(),
)
: PersonAvatar(size: _kPhotoSize, photoPath: photo),
),
),
),
const SizedBox(height: 16),
Text(
'사진을 눌러 프로필 이미지를 바꿔요',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodySmall!.copyWith(
fontSize: 12.5,
color: AppColors.muted,
height: AppTypography.normalHeight,
),
),
const SizedBox(height: 30),
RupField(
label: '닉네임',
child: RupTextField(
controller: _name,
hintText: '예: 나영',
maxLength: _kNicknameMaxLength,
textInputAction: TextInputAction.done,
onSubmitted: (_) => unawaited(_submit()),
),
),
RupSheetBleedButton(label: '수정 완료', onPressed: () => unawaited(_submit())),
],
),
);
}
}
class _AddPhotoIcon extends StatelessWidget {
const _AddPhotoIcon();
@override
Widget build(BuildContext context) {
return SvgPicture.asset(
AppAssets.addPhoto,
width: 30,
height: 30,
colorFilter: const ColorFilter.mode(AppColors.sandInk, BlendMode.srcIn),
);
}
}
@@ -0,0 +1,117 @@
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/heart_toggle.dart';
import 'package:tma_app/shared/widgets/icons/rup_icon.dart';
const double _kNormal = AppTypography.normalHeight;
/// 설정 줄 아이콘 색 (시안 MyPageRow tint)
abstract final class SettingsTints {
static const Color camera = Color(0xFF4DA6E0);
static const Color gallery = Color(0xFF3FB58C);
static const Color schedule = Color(0xFFFFAA4D);
static const Color event = Color(0xFFF2974E);
static const Color battery = Color(0xFF8E7CF0);
static const Color privacy = Color(0xFF3FB58C);
static const Color neutral = Color(0xFF9AA0A6);
static const Color account = Color(0xFF4DA6E0);
}
/// 설정 한 줄 (시안 MyPageRow) — 색 아이콘 + 제목 + (보조 문구) + › 또는 스위치
class SettingsRow extends StatelessWidget {
const SettingsRow({
super.key,
required this.icon,
required this.tint,
required this.title,
this.detail,
this.onTap,
this.first = false,
this.toggleValue,
this.onToggle,
});
final RupIconName icon;
final Color tint;
final String title;
final String? detail;
final VoidCallback? onTap;
/// 카드의 첫 줄이면 위 구분선을 그리지 않는다
final bool first;
/// 스위치 줄이면 값과 콜백을 준다
final bool? toggleValue;
final ValueChanged<bool>? onToggle;
bool get _isToggle => toggleValue != null;
@override
Widget build(BuildContext context) {
final textTheme = Theme.of(context).textTheme;
final row = Container(
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.lg, vertical: 14),
decoration: BoxDecoration(
border: first ? null : const Border(top: BorderSide(color: AppColors.line2)),
),
child: Row(
children: [
Container(
width: 32,
height: 32,
decoration: BoxDecoration(
color: tint.withValues(alpha: .12),
borderRadius: BorderRadius.circular(10),
),
alignment: Alignment.center,
child: RupIcon(icon, size: 18, stroke: 2, color: tint),
),
const SizedBox(width: 13),
Expanded(
child: Text(title, style: textTheme.titleSmall!.copyWith(fontWeight: FontWeight.w600, height: _kNormal)),
),
if (detail != null) ...[
Text(detail!, style: textTheme.bodySmall!.copyWith(fontSize: 13, color: AppColors.muted, height: _kNormal)),
const SizedBox(width: 13),
],
if (_isToggle)
HeartToggle(value: toggleValue!, onChanged: onToggle!, label: title)
else
const RupIcon(RupIconName.chevR, size: 17, color: AppColors.muted),
],
),
);
if (_isToggle) return row;
return Semantics(
container: true,
button: true,
child: GestureDetector(behavior: HitTestBehavior.opaque, onTap: onTap, child: row),
);
}
}
/// 설정 묶음 제목 — "앱 권한 설정", "알림 설정" 등
class SettingsGroupLabel extends StatelessWidget {
const SettingsGroupLabel(this.text, {super.key, this.top = 26});
final String text;
final double top;
@override
Widget build(BuildContext context) {
return Padding(
padding: EdgeInsets.fromLTRB(AppSpacing.xs, top, AppSpacing.xs, AppSpacing.sm),
child: Text(
text,
style: Theme.of(context).textTheme.bodySmall!.copyWith(
fontSize: 13,
fontWeight: FontWeight.w700,
color: AppColors.muted,
height: _kNormal,
),
),
);
}
}
@@ -27,6 +27,14 @@ class PetPhotoStore {
await file.saveTo(target);
return target;
}
/// 저장한 사진을 모두 지운다 — 회원탈퇴
Future<void> clearAll() async {
if (kIsWeb) return;
final docs = await getApplicationDocumentsDirectory();
final dir = Directory('${docs.path}/$_kPhotoDir');
if (dir.existsSync()) await dir.delete(recursive: true);
}
}
final petPhotoStoreProvider = Provider<PetPhotoStore>((ref) => const PetPhotoStore());
@@ -42,15 +42,23 @@ class LocalPetRepository implements PetRepository {
@override
Future<Pet> create(PetInput input) async {
final pet = Pet.fromInput('pet-${_clock().microsecondsSinceEpoch}', input);
await _write([..._read(), pet]);
final pets = _read();
final pet = Pet.fromInput(
'pet-${_clock().microsecondsSinceEpoch}',
input,
tint: petTintFor(pets.length),
);
await _write([...pets, pet]);
return pet;
}
@override
Future<Pet> update(String id, PetInput input) async {
final pet = Pet.fromInput(id, input);
await _write([for (final p in _read()) p.id == id ? pet : p]);
final pets = _read();
// 고유색은 앱이 정한 값이라 수정해도 유지한다
final tint = pets.firstWhere((p) => p.id == id).tint;
final pet = Pet.fromInput(id, input, tint: tint);
await _write([for (final p in pets) p.id == id ? pet : p]);
return pet;
}
+21 -1
View File
@@ -1,5 +1,17 @@
import 'dart:ui' show Color;
import 'package:tma_app/core/utils/color_utils.dart';
import 'package:tma_app/core/utils/date_format.dart';
/// 아이별 고유색 — 등록 순서대로 돌아가며 배정한다 (시안 buildPet 의 tints)
const List<Color> kPetTints = [
Color(0xFFFF9B6A), Color(0xFF6FB8E8), Color(0xFFC49A6C), Color(0xFF8E7BC8),
Color(0xFF7BBE7B), Color(0xFFE8A23D), Color(0xFF5BB0E0), Color(0xFFFF7AA0),
];
/// [index] 번째로 등록하는 아이의 색
Color petTintFor(int index) => kPetTints[index % kPetTints.length];
enum PetGender {
male('남아'),
female('여아');
@@ -76,6 +88,7 @@ class PetInput {
class Pet extends PetInput {
const Pet({
required this.id,
this.tint = const Color(0xFFFF9B6A),
required super.name,
required super.species,
required super.breed,
@@ -94,8 +107,10 @@ class Pet extends PetInput {
super.watchConditions,
});
factory Pet.fromInput(String id, PetInput input) => Pet(
/// [tint] 는 앱이 정한다 — 새로 만들 때 배정하고, 수정할 때는 기존 색을 넘긴다
factory Pet.fromInput(String id, PetInput input, {Color tint = const Color(0xFFFF9B6A)}) => Pet(
id: id,
tint: tint,
name: input.name,
species: input.species,
breed: input.breed,
@@ -116,6 +131,9 @@ class Pet extends PetInput {
final String id;
/// 목록 왼쪽 띠·칩 등에 쓰는 아이의 고유색
final Color tint;
/// 사진만 바꾼 입력값 — 명함 편집기의 카메라 버튼
PetInput withPhoto(String path) => PetInput(
name: name,
@@ -146,6 +164,7 @@ class Pet extends PetInput {
Map<String, Object?> toJson() => {
'id': id,
'tint': hexOf(tint),
'name': name,
'species': species,
'breed': breed,
@@ -166,6 +185,7 @@ class Pet extends PetInput {
factory Pet.fromJson(Map<String, Object?> json) => Pet(
id: json['id']! as String,
tint: colorFromHex(json['tint'] as String?) ?? kPetTints.first,
name: json['name']! as String,
species: json['species']! as String,
breed: json['breed'] as String? ?? '',
@@ -0,0 +1,363 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:go_router/go_router.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/core/router/app_routes.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/domain/pet.dart';
import 'package:tma_app/features/pet/domain/pet_age.dart';
import 'package:tma_app/features/pet/presentation/providers/pet_providers.dart';
import 'package:tma_app/features/pet/presentation/widgets/pet_photo_image.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' show kUnregistered;
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_card.dart';
import 'package:tma_app/shared/widgets/section_title.dart';
import 'package:tma_app/shared/widgets/sub_page_header.dart';
import 'package:tma_app/shared/widgets/tab_sub_page.dart';
const double _kPhotoSize = 76;
const double _kPhotoRadius = 20;
const Color _kPhotoBorder = Color(0xFFC8C8C8);
const double _kNormal = AppTypography.normalHeight;
/// 건강 정보 태그 색 (시안 TagField tone)
const Color _kAllergyTone = Color(0xFFE0534F);
const Color _kConditionTone = Color(0xFFC77A1F);
const Color _kWatchTone = Color(0xFF8E5BD0);
/// 반려동물 정보 — 기본·건강 정보를 읽기 전용으로 보여 주고, 연필을 누르면 수정
class PetInfoPage extends ConsumerWidget {
const PetInfoPage({super.key, required this.petId});
final String petId;
@override
Widget build(BuildContext context, WidgetRef ref) {
final loaded = ref.watch(petListProvider).hasValue;
final pet = ref.watch(petByIdProvider(petId));
if (!loaded) return const Scaffold(body: SizedBox.shrink());
if (pet == null) {
// 지워졌거나 잘못된 주소 — 목록으로 돌아갈 수 있게 머리는 남긴다
return TabSubPage(
children: [
ScreenPadding(child: SubPageHeader(title: '반려동물 정보', onBack: () => context.go(AppRoutes.myPets))),
const EmptyMessage('등록된 아이가 없어요', padding: 60),
],
);
}
final cardCount = ref.watch(myCardsProvider).where((c) => c.petId == pet.id).length;
final gender = '${pet.gender.label} · ${pet.neutered ? '중성화 완료' : '미중성화'}';
return TabSubPage(
children: [
ScreenPadding(
child: SubPageHeader(
title: '반려동물 정보',
subtitle: '${pet.name}의 상세 정보',
onBack: () => context.pop(),
trailing: Pressable(
onTap: () => unawaited(context.push(AppRoutes.petEdit(pet.id))),
pressedScale: .9,
semanticLabel: '${pet.name} 정보 수정',
child: const SizedBox.square(
dimension: 38,
child: Center(child: RupIcon(RupIconName.edit, size: 19, color: AppColors.ink2)),
),
),
),
),
ScreenPadding(
child: _IdentityCard(
pet: pet,
cardCount: cardCount,
onCardTap: () => unawaited(
context.push(cardCount == 0 ? AppRoutes.petCardCreate(pet.id) : AppRoutes.petCardManage),
),
),
),
const DividerBand(),
_Section(
title: '기본 정보',
children: [
_Field(label: '나이', value: petAgeLabel(pet.birthDate, DateTime.now())),
_Field(label: '생년월일', value: formatYmd(pet.birthDate)),
_Field(label: '성별', value: gender),
_Field(label: '몸무게', value: pet.weightLabel),
_Field(label: '혈액형', value: pet.bloodType ?? kUnregistered),
_Field(label: '마이크로칩', value: pet.microchip ?? kUnregistered),
],
),
const DividerBand(),
_Section(
title: '건강 정보',
children: [
_TagField(label: '알레르기', items: pet.allergies, tone: _kAllergyTone),
_TagField(label: '보유 질환', items: pet.conditions, tone: _kConditionTone),
_TagField(label: '염려 질환', items: pet.watchConditions, tone: _kWatchTone),
_Field(label: '담당 병원', value: pet.vet ?? kUnregistered),
],
),
if (pet.memo case final memo?) ...[
const DividerBand(),
_MemoSection(memo: memo),
],
],
);
}
}
/// 사진 · 이름 · 종 · 명함 바로가기
class _IdentityCard extends StatelessWidget {
const _IdentityCard({required this.pet, required this.cardCount, required this.onCardTap});
final Pet pet;
final int cardCount;
final VoidCallback onCardTap;
@override
Widget build(BuildContext context) {
final textTheme = Theme.of(context).textTheme;
final photo = pet.photoPath;
return RupCard(
padding: const EdgeInsets.all(AppSpacing.md),
child: Row(
children: [
Container(
width: _kPhotoSize,
height: _kPhotoSize,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(_kPhotoRadius),
border: Border.all(color: _kPhotoBorder, width: 1.5),
),
clipBehavior: Clip.antiAlias,
child: photo == null
? SvgPicture.asset(AppAssets.petDefault, fit: BoxFit.cover)
: PetPhotoImage(path: photo),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
pet.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontFamily: AppFonts.display, fontSize: 22, height: 1, color: AppColors.ink),
),
const SizedBox(height: 5),
Text(
pet.breed.isEmpty ? pet.species : '${pet.species} · ${pet.breed}',
style: textTheme.bodySmall!.copyWith(fontSize: 13, color: AppColors.muted, height: _kNormal),
),
const SizedBox(height: 9),
Semantics(
container: true,
button: true,
child: GestureDetector(
onTap: onCardTap,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 3),
decoration: BoxDecoration(
color: AppColors.primary50,
borderRadius: BorderRadius.circular(AppSpacing.sm),
border: Border.all(color: AppColors.primary100),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const RupGlyphIcon(RupGlyph.petCard, size: 13, color: AppColors.primary600),
const SizedBox(width: 5),
Text(
cardCount > 0 ? '보유 명함 $cardCount개' : '명함 만들기',
style: textTheme.bodySmall!.copyWith(
fontSize: 11.5,
fontWeight: FontWeight.w700,
color: AppColors.primary600,
height: _kNormal,
),
),
],
),
),
),
),
],
),
),
],
),
);
}
}
/// 섹션 제목 + 항목 카드 (항목 사이에 가는 줄)
class _Section extends StatelessWidget {
const _Section({required this.title, required this.children});
final String title;
final List<Widget> children;
@override
Widget build(BuildContext context) {
return ScreenPadding(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.xs),
child: SectionTitle(title: title),
),
const SizedBox(height: AppSpacing.md),
RupCard(
padding: const EdgeInsets.fromLTRB(18, 2, 18, 18),
child: Column(
children: [
for (final (i, child) in children.indexed)
// Container 는 테두리 두께만큼 자리를 잡는다 (시안 border-top 1px 도 줄 높이에 더해진다)
Container(
decoration: BoxDecoration(
border: i == 0 ? null : const Border(top: BorderSide(color: AppColors.line2)),
),
child: child,
),
],
),
),
],
),
);
}
}
class _Field extends StatelessWidget {
const _Field({required this.label, required this.value});
final String label;
final String value;
@override
Widget build(BuildContext context) {
final textTheme = Theme.of(context).textTheme;
return Padding(
padding: const EdgeInsets.symmetric(vertical: 13),
child: Row(
crossAxisAlignment: CrossAxisAlignment.baseline,
textBaseline: TextBaseline.alphabetic,
children: [
Text(label, style: _labelStyle(textTheme)),
const SizedBox(width: 14),
Expanded(
child: Text(
value,
textAlign: TextAlign.right,
style: textTheme.bodyMedium!.copyWith(
fontSize: 14.5,
fontWeight: FontWeight.w600,
color: AppColors.ink,
height: _kNormal,
),
),
),
],
),
);
}
}
TextStyle _labelStyle(TextTheme textTheme) =>
textTheme.bodyMedium!.copyWith(fontSize: 13.5, fontWeight: FontWeight.w600, color: AppColors.muted, height: _kNormal);
/// 태그 목록 항목 — 비어 있으면 "없음"
class _TagField extends StatelessWidget {
const _TagField({required this.label, required this.items, required this.tone});
final String label;
final List<String> items;
final Color tone;
@override
Widget build(BuildContext context) {
final textTheme = Theme.of(context).textTheme;
return Padding(
padding: const EdgeInsets.symmetric(vertical: 13),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(padding: const EdgeInsets.only(top: 3), child: Text(label, style: _labelStyle(textTheme))),
const SizedBox(width: 14),
Expanded(
child: items.isEmpty
? Text(
'없음',
textAlign: TextAlign.right,
style: textTheme.bodyMedium!.copyWith(fontSize: 14, color: AppColors.muted, height: _kNormal),
)
: Wrap(
alignment: WrapAlignment.end,
spacing: 5,
runSpacing: AppSpacing.xs,
children: [
for (final item in items)
Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: AppSpacing.xs),
decoration: BoxDecoration(
color: tone.withValues(alpha: .1),
borderRadius: BorderRadius.circular(10),
),
child: Text(
item,
style: textTheme.bodySmall!.copyWith(
fontSize: 12,
fontWeight: FontWeight.w600,
color: tone,
height: _kNormal,
),
),
),
],
),
),
],
),
);
}
}
class _MemoSection extends StatelessWidget {
const _MemoSection({required this.memo});
final String memo;
@override
Widget build(BuildContext context) {
return ScreenPadding(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Padding(
padding: EdgeInsets.symmetric(horizontal: AppSpacing.xs),
child: SectionTitle(title: '보호자 메모'),
),
const SizedBox(height: AppSpacing.md),
RupCard(
padding: const EdgeInsets.all(18),
child: Text(
memo,
style: Theme.of(context).textTheme.bodyMedium!.copyWith(fontSize: 14, height: 1.6, color: AppColors.ink2),
),
),
],
),
);
}
}
@@ -238,11 +238,14 @@ class _PetRegisterFormState extends ConsumerState<_PetRegisterForm> {
final pet = await notifier.create(_buildInput());
ref.read(selectedPetIdProvider.notifier).select(pet.id);
toast.show('${pet.name} 등록 완료!');
// 시안: 등록을 마치면 어디서 왔든 "나의 반려동물" 목록으로 간다
if (mounted) context.go(AppRoutes.myPets);
} else {
await notifier.updatePet(editing.id, _buildInput());
toast.show('반려동물 정보를 저장했어요');
// 수정은 반려동물 정보에서 열었으므로 그 화면으로 돌아간다
if (mounted) context.pop();
}
if (mounted) context.pop();
} finally {
if (mounted) setState(() => _saving = false);
}
@@ -263,7 +266,7 @@ class _PetRegisterFormState extends ConsumerState<_PetRegisterForm> {
// 아이의 펫 명함도 함께 지운다 (일정은 일정 기능을 만들 때 함께 정리한다)
await ref.read(petCardsProvider.notifier).deleteForPet(editing.id);
ref.read(toastProvider.notifier).show('반려동물을 삭제했어요');
if (mounted) context.go(AppRoutes.myPage);
if (mounted) context.go(AppRoutes.myPets);
}
// ── 화면 ──────────────────────────────────────────────
@@ -1,19 +1,27 @@
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/features/pet/domain/pet.dart';
import 'package:tma_app/features/pet/presentation/widgets/pet_photo_image.dart';
/// 사진이 없을 때 원 테두리 — 시안 .rup-avatar--slot
const Color _kEmptyRing = Color(0xFFC8C8C8);
/// [PetAvatar.ring] — 흰 틈 2.5 + 아이 색 고리 1.5 (시안 box-shadow 0 0 0 2.5px surface, 0 0 0 4px tint)
const double _kRingGap = 2.5;
const double _kRingOuter = 4;
/// 반려동물 원형 아바타 — 사진이 없으면 기본 강아지 얼굴 아이콘
class PetAvatar extends StatelessWidget {
const PetAvatar({super.key, required this.pet, required this.size});
const PetAvatar({super.key, required this.pet, required this.size, this.ring = false});
final Pet pet;
final double size;
/// 바깥에 아이 고유색 고리를 두른다 (나의 반려동물 목록)
final bool ring;
@override
Widget build(BuildContext context) {
final photo = pet.photoPath;
@@ -26,6 +34,13 @@ class PetAvatar extends StatelessWidget {
decoration: BoxDecoration(
shape: BoxShape.circle,
border: photo == null ? Border.all(color: _kEmptyRing, width: 1.5) : null,
// 뒤에 오는 그림자가 위에 그려진다 — 색 고리 위에 흰 틈을 얹는다
boxShadow: ring
? [
BoxShadow(color: pet.tint, spreadRadius: _kRingOuter),
const BoxShadow(color: AppColors.surface, spreadRadius: _kRingGap),
]
: null,
),
clipBehavior: Clip.antiAlias,
child: photo == null