generated from ttipo/project-base
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:
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user