From 1dba0c80abaec11077a0d913b889918038faf1e7 Mon Sep 17 00:00:00 2001 From: TtiPo Date: Mon, 28 Sep 2026 21:50:10 +0900 Subject: [PATCH] =?UTF-8?q?feat(flutter):=20=EB=A7=88=EC=9D=B4=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=C2=B7=EB=82=98=EC=9D=98=20=EB=B0=98=EB=A0=A4?= =?UTF-8?q?=EB=8F=99=EB=AC=BC=C2=B7=EB=B0=98=EB=A0=A4=EB=8F=99=EB=AC=BC=20?= =?UTF-8?q?=EC=A0=95=EB=B3=B4=C2=B7=EC=84=A4=EC=A0=95=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 시안은 아이를 등록하면 "나의 반려동물" 목록으로 보내므로 명함 다음에 만든다. - 마이페이지: 프로필(닉네임·사진 편집), 반려동물·명함 바로가기, 권한·알림 스위치 (스위치는 앱 설정값만 저장한다 — OS 권한·푸시는 해당 기능에서 요청) - 나의 반려동물 / 반려동물 정보(읽기 전용, 연필로 수정) — 아이마다 고유색을 배정한다 - 설정: 약관 문서, 계정 변경·로그아웃, 회원탈퇴(기기 기록 삭제 후 인트로로) - 등록·삭제 후와 홈 "아이 상세보기"는 나의 반려동물 목록으로 간다 시안 대조 중 발견한 공통 문제도 고쳤다. - 줄 높이를 주지 않은 글자가 본문 기본값(1.6)을 물려받아 시안보다 줄이 컸다 → AppTypography.normalHeight 를 두고 섹션 제목·시트 제목에 적용 (홈 간격 보정 포함) - 버튼이 옆 제목 글자와 한 의미 단위로 합쳐져 스크린리더가 함께 읽었다 → Pressable·설정 줄을 별도 의미 노드로 Co-Authored-By: Claude Opus 5.5 (1M context) --- flutter/assets/svg/common/art_card.svg | 25 ++ flutter/assets/svg/common/art_rup.svg | 31 ++ flutter/lib/core/constants/app_assets.dart | 4 + flutter/lib/core/constants/app_info.dart | 3 + flutter/lib/core/router/app_router.dart | 40 +- flutter/lib/core/router/app_routes.dart | 7 + .../data/repositories/auth_repository.dart | 7 + .../features/auth/domain/social_provider.dart | 12 +- .../presentation/providers/user_provider.dart | 14 +- .../home/presentation/pages/home_page.dart | 2 +- .../widgets/home_schedule_section.dart | 4 +- .../mypage/data/app_settings_repository.dart | 37 ++ .../mypage/data/profile_repository.dart | 54 +++ .../features/mypage/domain/policy_doc.dart | 46 +++ .../mypage/presentation/pages/my_page.dart | 281 ++++++++++++++ .../presentation/pages/my_pets_page.dart | 209 ++++++++++ .../presentation/pages/policy_doc_page.dart | 54 +++ .../presentation/pages/settings_page.dart | 174 +++++++++ .../providers/account_actions.dart | 42 ++ .../widgets/profile_edit_sheet.dart | 147 +++++++ .../presentation/widgets/settings_row.dart | 117 ++++++ .../features/pet/data/pet_photo_store.dart | 8 + .../pet/data/repositories/pet_repository.dart | 16 +- flutter/lib/features/pet/domain/pet.dart | 22 +- .../pet/presentation/pages/pet_info_page.dart | 363 ++++++++++++++++++ .../presentation/pages/pet_register_page.dart | 7 +- .../pet/presentation/widgets/pet_avatar.dart | 17 +- flutter/lib/shared/widgets/heart_toggle.dart | 68 ++++ flutter/lib/shared/widgets/person_avatar.dart | 33 ++ flutter/lib/shared/widgets/pressable.dart | 2 + flutter/lib/shared/widgets/rup_button.dart | 15 +- flutter/lib/shared/widgets/rup_dialog.dart | 18 + flutter/lib/shared/widgets/rup_sheet.dart | 5 +- flutter/lib/shared/widgets/section_title.dart | 9 +- flutter/lib/shared/widgets/tab_sub_page.dart | 42 ++ flutter/test/mypage_flow_test.dart | 135 +++++++ 36 files changed, 2049 insertions(+), 21 deletions(-) create mode 100644 flutter/assets/svg/common/art_card.svg create mode 100644 flutter/assets/svg/common/art_rup.svg create mode 100644 flutter/lib/core/constants/app_info.dart create mode 100644 flutter/lib/features/mypage/data/app_settings_repository.dart create mode 100644 flutter/lib/features/mypage/data/profile_repository.dart create mode 100644 flutter/lib/features/mypage/domain/policy_doc.dart create mode 100644 flutter/lib/features/mypage/presentation/pages/my_page.dart create mode 100644 flutter/lib/features/mypage/presentation/pages/my_pets_page.dart create mode 100644 flutter/lib/features/mypage/presentation/pages/policy_doc_page.dart create mode 100644 flutter/lib/features/mypage/presentation/pages/settings_page.dart create mode 100644 flutter/lib/features/mypage/presentation/providers/account_actions.dart create mode 100644 flutter/lib/features/mypage/presentation/widgets/profile_edit_sheet.dart create mode 100644 flutter/lib/features/mypage/presentation/widgets/settings_row.dart create mode 100644 flutter/lib/features/pet/presentation/pages/pet_info_page.dart create mode 100644 flutter/lib/shared/widgets/heart_toggle.dart create mode 100644 flutter/lib/shared/widgets/person_avatar.dart create mode 100644 flutter/lib/shared/widgets/tab_sub_page.dart create mode 100644 flutter/test/mypage_flow_test.dart diff --git a/flutter/assets/svg/common/art_card.svg b/flutter/assets/svg/common/art_card.svg new file mode 100644 index 0000000..48b466f --- /dev/null +++ b/flutter/assets/svg/common/art_card.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/flutter/assets/svg/common/art_rup.svg b/flutter/assets/svg/common/art_rup.svg new file mode 100644 index 0000000..5d189ff --- /dev/null +++ b/flutter/assets/svg/common/art_rup.svg @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/flutter/lib/core/constants/app_assets.dart b/flutter/lib/core/constants/app_assets.dart index c73a332..a0ad6a0 100644 --- a/flutter/lib/core/constants/app_assets.dart +++ b/flutter/lib/core/constants/app_assets.dart @@ -40,6 +40,10 @@ abstract final class AppAssets { static const String addPhoto = 'assets/svg/common/add_photo.svg'; static const String addCard = 'assets/svg/common/add_card.svg'; + /// 마이페이지 통계 줄의 그림 아이콘 — 반려동물 / 명함 관리 + static const String artRup = 'assets/svg/common/art_rup.svg'; + static const String artCard = 'assets/svg/common/art_card.svg'; + // 브랜드 로고 (명함 헤더 등) static const String logo = 'assets/images/brand/rup_logo.png'; static const String logoWhite = 'assets/images/brand/rup_logo_white.png'; diff --git a/flutter/lib/core/constants/app_info.dart b/flutter/lib/core/constants/app_info.dart new file mode 100644 index 0000000..9fa1084 --- /dev/null +++ b/flutter/lib/core/constants/app_info.dart @@ -0,0 +1,3 @@ +/// 앱 정보 — 화면에 보이는 버전 표기. +/// pubspec.yaml 의 version(빌드 번호 제외)과 맞춘다. +const String kAppVersion = '1.0.0'; diff --git a/flutter/lib/core/router/app_router.dart b/flutter/lib/core/router/app_router.dart index 35a513b..ba23ca3 100644 --- a/flutter/lib/core/router/app_router.dart +++ b/flutter/lib/core/router/app_router.dart @@ -7,7 +7,13 @@ import 'package:tma_app/features/auth/presentation/pages/login_page.dart'; import 'package:tma_app/features/entry/presentation/providers/entry_provider.dart'; import 'package:tma_app/features/home/presentation/pages/home_page.dart'; import 'package:tma_app/features/intro/presentation/pages/intro_page.dart'; +import 'package:tma_app/features/mypage/domain/policy_doc.dart'; +import 'package:tma_app/features/mypage/presentation/pages/my_page.dart'; +import 'package:tma_app/features/mypage/presentation/pages/my_pets_page.dart'; +import 'package:tma_app/features/mypage/presentation/pages/policy_doc_page.dart'; +import 'package:tma_app/features/mypage/presentation/pages/settings_page.dart'; import 'package:tma_app/features/onboarding/presentation/pages/onboarding_page.dart'; +import 'package:tma_app/features/pet/presentation/pages/pet_info_page.dart'; import 'package:tma_app/features/pet/presentation/pages/pet_register_page.dart'; import 'package:tma_app/features/pet_card/presentation/pages/pet_card_detail_page.dart'; import 'package:tma_app/features/pet_card/presentation/pages/pet_card_editor_page.dart'; @@ -131,8 +137,38 @@ final appRouterProvider = Provider((ref) { routes: [ GoRoute( path: AppRoutes.myPage, - builder: (context, state) => - const TabPlaceholderPage(title: '마이페이지'), + builder: (context, state) => const MyPage(), + // 탭바를 둔 채 넘어가는 하위 화면 + routes: [ + GoRoute( + path: 'pets', + builder: (context, state) => const MyPetsPage(), + routes: [ + GoRoute( + path: ':petId', + builder: (context, state) => + PetInfoPage(petId: state.pathParameters['petId']!), + ), + ], + ), + GoRoute( + path: 'settings', + builder: (context, state) => const SettingsPage(), + routes: [ + GoRoute( + path: 'policy/:doc', + // 모르는 문서 이름이면 설정으로 돌려보낸다 + redirect: (context, state) => + PolicyDoc.byName(state.pathParameters['doc']) == null + ? AppRoutes.settings + : null, + builder: (context, state) => PolicyDocPage( + doc: PolicyDoc.byName(state.pathParameters['doc'])!, + ), + ), + ], + ), + ], ), ], ), diff --git a/flutter/lib/core/router/app_routes.dart b/flutter/lib/core/router/app_routes.dart index d428df1..78c1a39 100644 --- a/flutter/lib/core/router/app_routes.dart +++ b/flutter/lib/core/router/app_routes.dart @@ -1,4 +1,5 @@ import 'package:tma_app/features/entry/domain/entry_state.dart'; +import 'package:tma_app/features/mypage/domain/policy_doc.dart'; /// 라우트 경로 상수 abstract final class AppRoutes { @@ -13,6 +14,12 @@ abstract final class AppRoutes { static const String petCards = '/pet-cards'; static const String myPage = '/mypage'; + // 마이페이지 하위 화면 (탭바 유지) + static const String myPets = '/mypage/pets'; + static String petInfo(String petId) => '/mypage/pets/$petId'; + static const String settings = '/mypage/settings'; + static String policy(PolicyDoc doc) => '/mypage/settings/policy/${doc.name}'; + // 탭 위에 덮는 화면 (탭바 없음) static const String petRegister = '/pets/new'; static const String petEditPattern = '/pets/:petId/edit'; diff --git a/flutter/lib/features/auth/data/repositories/auth_repository.dart b/flutter/lib/features/auth/data/repositories/auth_repository.dart index 8dd1e2f..893a5ad 100644 --- a/flutter/lib/features/auth/data/repositories/auth_repository.dart +++ b/flutter/lib/features/auth/data/repositories/auth_repository.dart @@ -13,6 +13,9 @@ abstract interface class AuthRepository { /// 인사말에 쓰는 이름 — 소셜 계정 프로필에서 받는다. 모르면 null String? get nickname; + /// 로그인한 소셜 계정 종류 — 로그인 전이면 null + SocialProvider? get provider; + Future signIn(SocialProvider provider); Future 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 signIn(SocialProvider provider) async { await _prefs.setString(_kDemoSessionKey, provider.name); diff --git a/flutter/lib/features/auth/domain/social_provider.dart b/flutter/lib/features/auth/domain/social_provider.dart index 8399ff8..4c021cc 100644 --- a/flutter/lib/features/auth/domain/social_provider.dart +++ b/flutter/lib/features/auth/domain/social_provider.dart @@ -1,2 +1,12 @@ /// 지원하는 소셜 로그인 제공자 -enum SocialProvider { naver, kakao, google, apple } +enum SocialProvider { + naver('네이버'), + kakao('카카오'), + google('구글'), + apple('Apple'); + + const SocialProvider(this.label); + + /// 화면 표기 — "카카오 계정으로 로그인했어요" + final String label; +} diff --git a/flutter/lib/features/auth/presentation/providers/user_provider.dart b/flutter/lib/features/auth/presentation/providers/user_provider.dart index 082bf01..ab59779 100644 --- a/flutter/lib/features/auth/presentation/providers/user_provider.dart +++ b/flutter/lib/features/auth/presentation/providers/user_provider.dart @@ -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((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((ref) { + ref.watch(entryProvider.select((s) => s.signedIn)); + return ref.watch(authRepositoryProvider).provider; }); diff --git a/flutter/lib/features/home/presentation/pages/home_page.dart b/flutter/lib/features/home/presentation/pages/home_page.dart index 676c365..e2b4425 100644 --- a/flutter/lib/features/home/presentation/pages/home_page.dart +++ b/flutter/lib/features/home/presentation/pages/home_page.dart @@ -47,7 +47,7 @@ class HomePage extends ConsumerWidget { title: '우리 아이', sub: true, trailing: '아이 상세보기', - onTrailingTap: () => context.go(AppRoutes.myPage), + onTrailingTap: () => context.go(AppRoutes.myPets), ), ), ), diff --git a/flutter/lib/features/home/presentation/widgets/home_schedule_section.dart b/flutter/lib/features/home/presentation/widgets/home_schedule_section.dart index f28c529..72c299e 100644 --- a/flutter/lib/features/home/presentation/widgets/home_schedule_section.dart +++ b/flutter/lib/features/home/presentation/widgets/home_schedule_section.dart @@ -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( diff --git a/flutter/lib/features/mypage/data/app_settings_repository.dart b/flutter/lib/features/mypage/data/app_settings_repository.dart new file mode 100644 index 0000000..277e63b --- /dev/null +++ b/flutter/lib/features/mypage/data/app_settings_repository.dart @@ -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> { + SharedPreferences get _prefs => ref.read(sharedPreferencesProvider); + + @override + Map build() { + final prefs = ref.watch(sharedPreferencesProvider); + return {for (final s in AppSetting.values) s: prefs.getBool(s.key) ?? s.defaultValue}; + } + + Future set(AppSetting setting, bool value) async { + state = {...state, setting: value}; + await _prefs.setBool(setting.key, value); + } +} + +final appSettingsProvider = NotifierProvider>( + AppSettingsNotifier.new, +); diff --git a/flutter/lib/features/mypage/data/profile_repository.dart b/flutter/lib/features/mypage/data/profile_repository.dart new file mode 100644 index 0000000..9597af3 --- /dev/null +++ b/flutter/lib/features/mypage/data/profile_repository.dart @@ -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 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((ref) { + return ProfileRepository(ref.watch(sharedPreferencesProvider)); +}); + +class ProfileNotifier extends Notifier { + @override + UserProfile build() => ref.watch(profileRepositoryProvider).load(); + + /// 넘긴 값만 바꾼다 (null 이면 기존 값 유지) + Future 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.new); diff --git a/flutter/lib/features/mypage/domain/policy_doc.dart b/flutter/lib/features/mypage/domain/policy_doc.dart new file mode 100644 index 0000000..d6429f3 --- /dev/null +++ b/flutter/lib/features/mypage/domain/policy_doc.dart @@ -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 sections; + + /// 경로의 문서 이름 → 문서 (모르는 이름이면 null) + static PolicyDoc? byName(String? name) => + PolicyDoc.values.where((d) => d.name == name).firstOrNull; +} diff --git a/flutter/lib/features/mypage/presentation/pages/my_page.dart b/flutter/lib/features/mypage/presentation/pages/my_page.dart new file mode 100644 index 0000000..aab1c51 --- /dev/null +++ b/flutter/lib/features/mypage/presentation/pages/my_page.dart @@ -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 _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 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 []).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)), + ], + ), + ], + ), + ), + ), + ); + } +} diff --git a/flutter/lib/features/mypage/presentation/pages/my_pets_page.dart b/flutter/lib/features/mypage/presentation/pages/my_pets_page.dart new file mode 100644 index 0000000..e6b1146 --- /dev/null +++ b/flutter/lib/features/mypage/presentation/pages/my_pets_page.dart @@ -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 []; + 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, + ), + ), + ); + } +} diff --git a/flutter/lib/features/mypage/presentation/pages/policy_doc_page.dart b/flutter/lib/features/mypage/presentation/pages/policy_doc_page.dart new file mode 100644 index 0000000..1d797dd --- /dev/null +++ b/flutter/lib/features/mypage/presentation/pages/policy_doc_page.dart @@ -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), + ), + ], + ], + ), + ), + ), + ], + ); + } +} diff --git a/flutter/lib/features/mypage/presentation/pages/settings_page.dart b/flutter/lib/features/mypage/presentation/pages/settings_page.dart new file mode 100644 index 0000000..d70191d --- /dev/null +++ b/flutter/lib/features/mypage/presentation/pages/settings_page.dart @@ -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 _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 _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, + ), + ), + ), + ), + ), + ], + ), + ), + ], + ); + } +} diff --git a/flutter/lib/features/mypage/presentation/providers/account_actions.dart b/flutter/lib/features/mypage/presentation/providers/account_actions.dart new file mode 100644 index 0000000..37c1083 --- /dev/null +++ b/flutter/lib/features/mypage/presentation/providers/account_actions.dart @@ -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 logout() => _ref.read(entryProvider.notifier).signOut(); + + /// 회원탈퇴 — 이 기기에 저장한 기록(아이·명함·사진·설정)을 모두 지우고 처음(인트로)으로 돌아간다. + /// 서버 회원 API 가 생기면 탈퇴 요청을 먼저 보낸다. + Future 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.new); diff --git a/flutter/lib/features/mypage/presentation/widgets/profile_edit_sheet.dart b/flutter/lib/features/mypage/presentation/widgets/profile_edit_sheet.dart new file mode 100644 index 0000000..3884545 --- /dev/null +++ b/flutter/lib/features/mypage/presentation/widgets/profile_edit_sheet.dart @@ -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 showProfileEditSheet(BuildContext context) { + return showRupSheet( + 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 _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 _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), + ); + } +} diff --git a/flutter/lib/features/mypage/presentation/widgets/settings_row.dart b/flutter/lib/features/mypage/presentation/widgets/settings_row.dart new file mode 100644 index 0000000..9f149bf --- /dev/null +++ b/flutter/lib/features/mypage/presentation/widgets/settings_row.dart @@ -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? 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, + ), + ), + ); + } +} diff --git a/flutter/lib/features/pet/data/pet_photo_store.dart b/flutter/lib/features/pet/data/pet_photo_store.dart index b2be49c..0c2a751 100644 --- a/flutter/lib/features/pet/data/pet_photo_store.dart +++ b/flutter/lib/features/pet/data/pet_photo_store.dart @@ -27,6 +27,14 @@ class PetPhotoStore { await file.saveTo(target); return target; } + + /// 저장한 사진을 모두 지운다 — 회원탈퇴 + Future 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((ref) => const PetPhotoStore()); diff --git a/flutter/lib/features/pet/data/repositories/pet_repository.dart b/flutter/lib/features/pet/data/repositories/pet_repository.dart index 3d14b0b..d67ecec 100644 --- a/flutter/lib/features/pet/data/repositories/pet_repository.dart +++ b/flutter/lib/features/pet/data/repositories/pet_repository.dart @@ -42,15 +42,23 @@ class LocalPetRepository implements PetRepository { @override Future 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 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; } diff --git a/flutter/lib/features/pet/domain/pet.dart b/flutter/lib/features/pet/domain/pet.dart index 9a461ff..8ceeced 100644 --- a/flutter/lib/features/pet/domain/pet.dart +++ b/flutter/lib/features/pet/domain/pet.dart @@ -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 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 toJson() => { 'id': id, + 'tint': hexOf(tint), 'name': name, 'species': species, 'breed': breed, @@ -166,6 +185,7 @@ class Pet extends PetInput { factory Pet.fromJson(Map 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? ?? '', diff --git a/flutter/lib/features/pet/presentation/pages/pet_info_page.dart b/flutter/lib/features/pet/presentation/pages/pet_info_page.dart new file mode 100644 index 0000000..ba79d10 --- /dev/null +++ b/flutter/lib/features/pet/presentation/pages/pet_info_page.dart @@ -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 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 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), + ), + ), + ], + ), + ); + } +} diff --git a/flutter/lib/features/pet/presentation/pages/pet_register_page.dart b/flutter/lib/features/pet/presentation/pages/pet_register_page.dart index 24fc830..1716321 100644 --- a/flutter/lib/features/pet/presentation/pages/pet_register_page.dart +++ b/flutter/lib/features/pet/presentation/pages/pet_register_page.dart @@ -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); } // ── 화면 ────────────────────────────────────────────── diff --git a/flutter/lib/features/pet/presentation/widgets/pet_avatar.dart b/flutter/lib/features/pet/presentation/widgets/pet_avatar.dart index 7e29d4d..cdf9859 100644 --- a/flutter/lib/features/pet/presentation/widgets/pet_avatar.dart +++ b/flutter/lib/features/pet/presentation/widgets/pet_avatar.dart @@ -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 diff --git a/flutter/lib/shared/widgets/heart_toggle.dart b/flutter/lib/shared/widgets/heart_toggle.dart new file mode 100644 index 0000000..f30f3ab --- /dev/null +++ b/flutter/lib/shared/widgets/heart_toggle.dart @@ -0,0 +1,68 @@ +import 'package:flutter/material.dart'; +import 'package:tma_app/core/constants/app_colors.dart'; + +// 시안 .gx-toggle — 44×22, 안쪽 여백 3.5, 흰 판이 좌우로 22 씩 미끄러진다 +const double _kWidth = 44; +const double _kHeight = 22; +const double _kInset = 3.5; +const double _kShift = 22; +const Duration _kSlide = Duration(milliseconds: 850); +const Cubic _kSlideCurve = Cubic(.4, .8, .35, 1); +const Color _kOffTrack = Color(0xFFECE7DF); + +/// 설정 켜고 끄기 (시안 HeartToggle) +class HeartToggle extends StatelessWidget { + const HeartToggle({super.key, required this.value, required this.onChanged, required this.label}); + + final bool value; + final ValueChanged onChanged; + final String label; + + @override + Widget build(BuildContext context) { + const innerWidth = _kWidth - _kInset * 2; + return Semantics( + container: true, + toggled: value, + label: label, + button: true, + excludeSemantics: true, + child: GestureDetector( + onTap: () => onChanged(!value), + child: AnimatedContainer( + duration: const Duration(milliseconds: 500), + width: _kWidth, + height: _kHeight, + padding: const EdgeInsets.all(_kInset), + decoration: BoxDecoration( + color: value ? AppColors.primary : _kOffTrack, + borderRadius: BorderRadius.circular(20), + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(20), + child: Stack( + clipBehavior: Clip.hardEdge, + children: [ + AnimatedPositioned( + duration: _kSlide, + curve: _kSlideCurve, + left: value ? _kShift : -_kShift, + top: 0, + bottom: 0, + width: innerWidth, + child: DecoratedBox( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(20), + boxShadow: const [BoxShadow(color: Color(0x24000000), blurRadius: 8, spreadRadius: 2)], + ), + ), + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/flutter/lib/shared/widgets/person_avatar.dart b/flutter/lib/shared/widgets/person_avatar.dart new file mode 100644 index 0000000..0b95f52 --- /dev/null +++ b/flutter/lib/shared/widgets/person_avatar.dart @@ -0,0 +1,33 @@ +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +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'; + +/// 사람(보호자) 원형 아바타 — 사진이 없으면 기본 사람 아이콘 +class PersonAvatar extends StatelessWidget { + const PersonAvatar({super.key, required this.size, this.photoPath}); + + final double size; + final String? photoPath; + + @override + Widget build(BuildContext context) { + final photo = photoPath; + Widget error(BuildContext c, Object e, StackTrace? s) => + SvgPicture.asset(AppAssets.personDefault, fit: BoxFit.cover); + return Container( + width: size, + height: size, + decoration: const BoxDecoration(color: AppColors.surface2, shape: BoxShape.circle), + clipBehavior: Clip.antiAlias, + child: photo == null + ? SvgPicture.asset(AppAssets.personDefault, fit: BoxFit.cover) + : kIsWeb + ? Image.network(photo, fit: BoxFit.cover, errorBuilder: error) + : Image.file(File(photo), fit: BoxFit.cover, errorBuilder: error), + ); + } +} diff --git a/flutter/lib/shared/widgets/pressable.dart b/flutter/lib/shared/widgets/pressable.dart index ff05bda..92efa7c 100644 --- a/flutter/lib/shared/widgets/pressable.dart +++ b/flutter/lib/shared/widgets/pressable.dart @@ -37,6 +37,8 @@ class _PressableState extends State { Widget build(BuildContext context) { final enabled = widget.onTap != null; return Semantics( + // 버튼마다 따로 읽히게 — 옆의 제목 글자와 한 덩어리로 합쳐지지 않도록 + container: true, button: true, enabled: enabled, label: widget.semanticLabel, diff --git a/flutter/lib/shared/widgets/rup_button.dart b/flutter/lib/shared/widgets/rup_button.dart index ebc3b2c..9f796a1 100644 --- a/flutter/lib/shared/widgets/rup_button.dart +++ b/flutter/lib/shared/widgets/rup_button.dart @@ -32,14 +32,23 @@ const double _kPadV = 13; const double _kPadVLarge = 16; const double _kPadH = 18; -/// 머리 오른쪽의 작은 연주황 버튼 — "+ 명함 만들기" 등 +/// 머리 오른쪽의 작은 연주황 버튼 — "+ 명함 만들기", "+ 아이 등록하기", "편집" class RupSoftPillButton extends StatelessWidget { - const RupSoftPillButton({super.key, required this.label, required this.onPressed, this.showPlus = true}); + const RupSoftPillButton({ + super.key, + required this.label, + required this.onPressed, + this.showPlus = true, + this.background = AppColors.surface2, + }); final String label; final VoidCallback onPressed; final bool showPlus; + /// 머리의 "+" 버튼은 더 옅은 바탕, 프로필 "편집"은 기본 .rup-btn--soft 바탕(primary50) + final Color background; + @override Widget build(BuildContext context) { return Pressable( @@ -47,7 +56,7 @@ class RupSoftPillButton extends StatelessWidget { child: Container( padding: EdgeInsets.symmetric(horizontal: 14, vertical: showPlus ? AppSpacing.sm : 9), decoration: BoxDecoration( - color: AppColors.surface2, + color: background, borderRadius: BorderRadius.circular(AppRadius.pill), border: Border.all(color: AppColors.primary100, width: 1.5), ), diff --git a/flutter/lib/shared/widgets/rup_dialog.dart b/flutter/lib/shared/widgets/rup_dialog.dart index 71dd9ad..f0919ca 100644 --- a/flutter/lib/shared/widgets/rup_dialog.dart +++ b/flutter/lib/shared/widgets/rup_dialog.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:tma_app/core/constants/app_colors.dart'; import 'package:tma_app/core/constants/app_sizes.dart'; import 'package:tma_app/core/theme/app_shadows.dart'; +import 'package:tma_app/shared/widgets/icons/rup_icon.dart'; import 'package:tma_app/shared/widgets/rup_button.dart'; const double _kDialogMaxWidth = 300; @@ -24,6 +25,9 @@ Future showRupConfirm( String cancelLabel = '취소', bool destructive = false, RupConfirmLayout layout = RupConfirmLayout.stacked, + RupIconName? icon, + Color iconColor = AppColors.ink2, + Color? iconBackground, }) async { final result = await showDialog( context: context, @@ -51,6 +55,20 @@ Future showRupConfirm( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ if (row) const SizedBox(height: AppSpacing.xs), + if (icon != null) + Center( + child: Container( + width: 54, + height: 54, + margin: const EdgeInsets.only(bottom: 14), + decoration: BoxDecoration( + color: iconBackground ?? iconColor.withValues(alpha: .13), + borderRadius: BorderRadius.circular(AppSpacing.lg), + ), + alignment: Alignment.center, + child: RupIcon(icon, size: 26, stroke: 2, color: iconColor), + ), + ), Text( title, textAlign: TextAlign.center, diff --git a/flutter/lib/shared/widgets/rup_sheet.dart b/flutter/lib/shared/widgets/rup_sheet.dart index 911377a..9be6730 100644 --- a/flutter/lib/shared/widgets/rup_sheet.dart +++ b/flutter/lib/shared/widgets/rup_sheet.dart @@ -84,7 +84,10 @@ class _RupSheetBody extends StatelessWidget { child: Text( title!, textAlign: TextAlign.center, - style: Theme.of(context).textTheme.titleMedium!.copyWith(fontSize: 18), + style: Theme.of(context).textTheme.titleMedium!.copyWith( + fontSize: 18, + height: AppTypography.displayNormalHeight, + ), ), ), Flexible(child: child), diff --git a/flutter/lib/shared/widgets/section_title.dart b/flutter/lib/shared/widgets/section_title.dart index d3b50eb..c7f75c9 100644 --- a/flutter/lib/shared/widgets/section_title.dart +++ b/flutter/lib/shared/widgets/section_title.dart @@ -1,8 +1,11 @@ import 'package:flutter/material.dart'; import 'package:tma_app/core/constants/app_colors.dart'; import 'package:tma_app/core/constants/app_sizes.dart'; +import 'package:tma_app/core/theme/app_typography.dart'; import 'package:tma_app/shared/widgets/icons/rup_icon.dart'; +const double _kTitleLinkPad = 2; + /// 섹션 제목 줄 — 왼쪽 제목, 오른쪽 "더보기 ›" 같은 보조 링크나 설명 (시안 .rup-section-title) class SectionTitle extends StatelessWidget { const SectionTitle({ @@ -32,7 +35,8 @@ class SectionTitle extends StatelessWidget { color: AppColors.muted, height: 1.2, ) - : textTheme.titleMedium!.copyWith(fontWeight: FontWeight.w600); + // 줄 높이를 지정하지 않으면 본문 기본값(1.6)을 물려받아 시안 h2(글자 높이 그대로)보다 줄이 커진다 + : textTheme.titleMedium!.copyWith(fontWeight: FontWeight.w600, height: AppTypography.displayNormalHeight); final trailingStyle = textTheme.bodySmall!.copyWith( fontSize: 13, color: AppColors.muted, @@ -53,7 +57,8 @@ class SectionTitle extends StatelessWidget { behavior: HitTestBehavior.opaque, onTap: onTrailingTap, child: Padding( - padding: const EdgeInsets.symmetric(vertical: AppSpacing.xs), + // 터치 여백은 제목 줄 높이를 넘지 않는 만큼만 — 넘치면 줄이 시안보다 커진다 + padding: EdgeInsets.symmetric(vertical: sub ? AppSpacing.xs : _kTitleLinkPad), child: Row( mainAxisSize: MainAxisSize.min, children: [ diff --git a/flutter/lib/shared/widgets/tab_sub_page.dart b/flutter/lib/shared/widgets/tab_sub_page.dart new file mode 100644 index 0000000..c3788ec --- /dev/null +++ b/flutter/lib/shared/widgets/tab_sub_page.dart @@ -0,0 +1,42 @@ +import 'package:flutter/material.dart'; +import 'package:tma_app/core/constants/app_sizes.dart'; + +/// 탭바 뒤로 콘텐츠가 가려지지 않게 두는 여유 +const double _kBottomGap = 30; + +/// 탭바를 그대로 둔 채 탭 안에서 넘어가는 하위 화면의 틀 (나의 반려동물·설정 등). +/// 자식은 스스로 좌우 여백을 둔다 — 구분 띠([DividerBand])는 화면 끝까지 닿아야 하므로. +class TabSubPage extends StatelessWidget { + const TabSubPage({super.key, required this.children}); + + final List children; + + @override + Widget build(BuildContext context) { + return Scaffold( + body: SafeArea( + bottom: false, + child: ListView( + padding: EdgeInsets.only( + top: AppSpacing.xs, + // 탭바 높이는 바깥 셸(extendBody)이 padding.bottom 에 더해 준다 + bottom: MediaQuery.paddingOf(context).bottom + _kBottomGap, + ), + children: children, + ), + ), + ); + } +} + +/// 화면 좌우 기본 여백(20) +class ScreenPadding extends StatelessWidget { + const ScreenPadding({super.key, required this.child}); + + final Widget child; + + @override + Widget build(BuildContext context) { + return Padding(padding: const EdgeInsets.symmetric(horizontal: AppSpacing.xl), child: child); + } +} diff --git a/flutter/test/mypage_flow_test.dart b/flutter/test/mypage_flow_test.dart new file mode 100644 index 0000000..de0b81b --- /dev/null +++ b/flutter/test/mypage_flow_test.dart @@ -0,0 +1,135 @@ +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:tma_app/features/auth/presentation/pages/login_page.dart'; +import 'package:tma_app/features/intro/presentation/pages/intro_page.dart'; +import 'package:tma_app/features/mypage/presentation/pages/my_pets_page.dart'; +import 'package:tma_app/features/mypage/presentation/pages/settings_page.dart'; +import 'package:tma_app/features/pet/presentation/pages/pet_info_page.dart'; +import 'package:tma_app/features/pet/presentation/pages/pet_register_page.dart'; + +import 'support/app_harness.dart'; + +Map _withPet() => {'pets.v1': jsonEncode([samplePet().toJson()])}; + +Future _openMyPage(WidgetTester tester) async { + await tester.tap(find.bySemanticsLabel('마이페이지')); + await settle(tester); +} + +Future _openSettings(WidgetTester tester) async { + await _openMyPage(tester); + await tester.tap(find.bySemanticsLabel('설정')); + await settle(tester); + expect(find.byType(SettingsPage), findsOneWidget); +} + +void main() { + testWidgets('홈의 "아이 상세보기"는 나의 반려동물 목록으로, 아이를 누르면 반려동물 정보로 간다', (tester) async { + await launchSignedIn(tester, extra: _withPet()); + + await tester.tap(find.text('아이 상세보기')); + await settle(tester); + expect(find.byType(MyPetsPage), findsOneWidget); + expect(find.text('1마리와 함께해요'), findsOneWidget); + expect(find.text('강아지 · 말티즈'), findsOneWidget); + expect(find.text('3.2kg'), findsOneWidget); + + await tester.tap(find.text('콩이')); + await settle(tester); + expect(find.byType(PetInfoPage), findsOneWidget); + expect(find.text('콩이의 상세 정보'), findsOneWidget); + expect(find.text('2021.09.14'), findsOneWidget); + expect(find.text('남아 · 중성화 완료'), findsOneWidget); + // 혈액형·마이크로칩·담당 병원을 입력하지 않았다 + expect(find.text('미등록'), findsNWidgets(3)); + // 알레르기·보유 질환·염려 질환이 비어 있다 + expect(find.text('없음'), findsNWidgets(3)); + expect(find.text('명함 만들기'), findsOneWidget); + + // 연필 → 수정 화면, 뒤로 → 정보 화면 + await tester.tap(find.bySemanticsLabel('콩이 정보 수정')); + await settle(tester); + expect(find.byType(PetRegisterPage), findsOneWidget); + expect(find.text('반려동물 정보 수정'), findsOneWidget); + }); + + testWidgets('마이페이지는 로그인 계정과 아이 수·명함 수를 보여 준다', (tester) async { + await launchSignedIn(tester, extra: _withPet()); + await _openMyPage(tester); + + expect(find.text('카카오 계정으로 로그인했어요'), findsOneWidget); + expect(find.text('1마리'), findsOneWidget); + expect(find.text('0장'), findsOneWidget); + expect(find.text('카메라 접근 권한'), findsOneWidget); + }); + + testWidgets('알림 스위치를 바꾸면 저장되고 안내가 뜬다', (tester) async { + final prefs = await launchSignedIn(tester); + await _openMyPage(tester); + + await tester.tap(find.bySemanticsLabel('혜택 및 이벤트 알림')); + await settle(tester); + expect(prefs.getBool('settings.notiEvent'), isTrue); + expect(find.text('혜택·이벤트 알림을 켰어요'), findsOneWidget); + }); + + testWidgets('프로필 편집에서 닉네임을 바꾸면 홈 인사말도 바뀐다', (tester) async { + final prefs = await launchSignedIn(tester); + await _openMyPage(tester); + + await tester.tap(find.text('편집')); + await settle(tester); + expect(find.text('프로필 편집'), findsOneWidget); + + await tester.enterText(find.byType(TextField), '나영'); + await tester.tap(find.text('수정 완료')); + await settle(tester); + expect(prefs.getString('profile.nickname'), '나영'); + expect(find.text('나영'), findsOneWidget); + + await tester.tap(find.bySemanticsLabel('홈')); + await settle(tester); + expect(find.text('안녕하세요, 나영님'), findsOneWidget); + }); + + testWidgets('설정에서 약관 문서를 열 수 있다', (tester) async { + await launchSignedIn(tester); + await _openSettings(tester); + + await tester.tap(find.text('이용약관')); + await settle(tester); + expect(find.text('제1조 (목적)'), findsOneWidget); + expect(find.text('2026년 1월 1일 시행'), findsOneWidget); + }); + + testWidgets('로그아웃을 확인하면 로그인 화면으로 가고 기록은 남는다', (tester) async { + final prefs = await launchSignedIn(tester, extra: _withPet()); + await _openSettings(tester); + + await tester.tap(find.text('로그아웃')); + await settle(tester); + expect(find.text('로그아웃할까요?'), findsOneWidget); + + await tester.tap(find.text('로그아웃').last); + await settle(tester); + expect(find.byType(LoginPage), findsOneWidget); + expect(prefs.getString('pets.v1'), isNotNull); + }); + + testWidgets('회원탈퇴를 확인하면 기기의 기록을 지우고 인트로로 돌아간다', (tester) async { + final prefs = await launchSignedIn(tester, extra: _withPet()); + await _openSettings(tester); + + await tester.tap(find.text('회원탈퇴')); + await settle(tester); + expect(find.text('정말 탈퇴할까요?'), findsOneWidget); + + await tester.tap(find.text('탈퇴하기')); + await settle(tester); + expect(find.byType(IntroPage), findsOneWidget); + expect(prefs.getString('pets.v1'), isNull); + expect(prefs.getString('auth.demoSession'), isNull); + }); +}