refactor(flutter): 대상 아바타·칩을 반려동물 공용으로 옮기고 사진 칸·토스트 보완

다이어리도 "누구의 기록인지"(아이·나)를 고르므로 일정 전용이던 부품을 공용으로 옮긴다.
- TargetAvatar·PetStack·TargetChip → features/pet (칩에 선택 ✓ 표시 추가), "나" id 는 pet_target.dart
- 사진 칸에 높이·모서리·빈 칸 문구 지정, 앨범에서 골라 저장하는 도우미
- 여러 줄 입력칸 최소 높이 지정, "오늘·어제·N일 전" 날짜 표기
- 긴 토스트 문구가 화면 밖으로 넘치던 문제 — 넘치면 줄을 바꾼다

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-28 22:57:30 +09:00
parent a32c0d1337
commit 76e9fc1af1
12 changed files with 155 additions and 69 deletions
@@ -9,11 +9,11 @@ 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/presentation/widgets/target_avatar.dart';
import 'package:tma_app/features/records/presentation/providers/records_tab_provider.dart';
import 'package:tma_app/features/schedule/domain/schedule_event.dart';
import 'package:tma_app/features/schedule/presentation/providers/schedule_providers.dart';
import 'package:tma_app/features/schedule/presentation/widgets/event_tile.dart';
import 'package:tma_app/features/schedule/presentation/widgets/target_avatar.dart';
import 'package:tma_app/shared/widgets/icons/rup_icon.dart';
import 'package:tma_app/shared/widgets/rup_card.dart';
import 'package:tma_app/shared/widgets/section_title.dart';
@@ -38,3 +38,19 @@ class PetPhotoStore {
}
final petPhotoStoreProvider = Provider<PetPhotoStore>((ref) => const PetPhotoStore());
/// 사진을 고를 때 줄이는 크기 — 원본 그대로 두면 저장 공간을 많이 쓴다
const double kPhotoMaxSide = 1600;
const int kPhotoQuality = 85;
/// 앨범에서 사진 하나를 골라 앱 폴더에 저장하고 경로를 돌려준다 — 고르지 않으면 null
Future<String?> pickAndStorePhoto(PetPhotoStore store, {double maxSide = kPhotoMaxSide}) async {
final file = await ImagePicker().pickImage(
source: ImageSource.gallery,
maxWidth: maxSide,
maxHeight: maxSide,
imageQuality: kPhotoQuality,
);
if (file == null) return null;
return store.persist(file);
}
@@ -0,0 +1,2 @@
/// 일정·다이어리의 대상 중 "나(보호자 본인)" — 반려동물 id 와 같은 자리에 둔다 (시안 SELF_PET)
const String kSelfTargetId = 'self';
@@ -11,28 +11,50 @@ const double _kSlotHeight = 200;
const double _kSlotRadius = 16;
const double _kEditBadge = 34;
/// 반려동물 사진 칸 — 비어 있으면 "우리 아이 사진 추가하기", 있으면 사진 + 바꾸기 표시
/// 빈 사진 칸 기본 문구
const String kAddPetPhotoLabel = '우리 아이 사진 추가하기';
/// 사진 칸 — 비어 있으면 "우리 아이 사진 추가하기", 있으면 사진 + 바꾸기 표시.
/// 반려동물 등록 · 다이어리 기록 · 다이어리 표지에서 쓴다.
class PetPhotoSlot extends StatelessWidget {
const PetPhotoSlot({super.key, required this.photoPath, required this.onPick});
const PetPhotoSlot({
super.key,
required this.photoPath,
required this.onPick,
this.height = _kSlotHeight,
this.radius = _kSlotRadius,
this.label = kAddPetPhotoLabel,
});
final String? photoPath;
final VoidCallback onPick;
/// 칸 높이 — null 이면 가로와 같은 정사각형
final double? height;
final double radius;
/// 비어 있을 때 문구
final String label;
Widget _sized(Widget child) {
final h = height;
if (h == null) return AspectRatio(aspectRatio: 1, child: child);
return SizedBox(height: h, width: double.infinity, child: child);
}
@override
Widget build(BuildContext context) {
final photo = photoPath;
return Semantics(
button: true,
label: photo == null ? '우리 아이 사진 추가하기' : '우리 아이 사진 바꾸기',
label: photo == null ? label : '사진 바꾸기',
excludeSemantics: true,
child: MouseRegion(
cursor: SystemMouseCursors.click,
child: GestureDetector(
onTap: onPick,
child: SizedBox(
height: _kSlotHeight,
width: double.infinity,
child: photo == null ? const _EmptySlot() : _FilledSlot(path: photo),
child: _sized(
photo == null ? _EmptySlot(label: label, radius: radius) : _FilledSlot(path: photo, radius: radius),
),
),
),
@@ -41,14 +63,17 @@ class PetPhotoSlot extends StatelessWidget {
}
class _EmptySlot extends StatelessWidget {
const _EmptySlot();
const _EmptySlot({required this.label, required this.radius});
final String label;
final double radius;
@override
Widget build(BuildContext context) {
return DecoratedBox(
decoration: BoxDecoration(
color: AppColors.sand,
borderRadius: BorderRadius.circular(_kSlotRadius),
borderRadius: BorderRadius.circular(radius),
),
child: Center(
child: Column(
@@ -61,9 +86,9 @@ class _EmptySlot extends StatelessWidget {
colorFilter: const ColorFilter.mode(AppColors.sandInk, BlendMode.srcIn),
),
const SizedBox(height: 12),
const Text(
'우리 아이 사진 추가하기',
style: TextStyle(
Text(
label,
style: const TextStyle(
fontFamily: AppFonts.display,
fontSize: 13.5,
fontWeight: FontWeight.w600,
@@ -78,9 +103,10 @@ class _EmptySlot extends StatelessWidget {
}
class _FilledSlot extends StatelessWidget {
const _FilledSlot({required this.path});
const _FilledSlot({required this.path, required this.radius});
final String path;
final double radius;
@override
Widget build(BuildContext context) {
@@ -88,7 +114,7 @@ class _FilledSlot extends StatelessWidget {
fit: StackFit.expand,
children: [
ClipRRect(
borderRadius: BorderRadius.circular(_kSlotRadius),
borderRadius: BorderRadius.circular(radius),
child: PetPhotoImage(path: path),
),
// 사진을 바꿀 수 있다는 표시
@@ -1,11 +1,14 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:tma_app/core/constants/app_colors.dart';
import 'package:tma_app/core/constants/app_sizes.dart';
import 'package:tma_app/core/theme/app_typography.dart';
import 'package:tma_app/features/mypage/data/profile_repository.dart';
import 'package:tma_app/features/pet/domain/pet.dart';
import 'package:tma_app/features/pet/domain/pet_target.dart';
import 'package:tma_app/features/pet/presentation/providers/pet_providers.dart';
import 'package:tma_app/features/pet/presentation/widgets/pet_avatar.dart';
import 'package:tma_app/features/schedule/domain/schedule_event.dart';
import 'package:tma_app/shared/widgets/icons/rup_icon.dart';
import 'package:tma_app/shared/widgets/person_avatar.dart';
/// 일정 대상 "나" 의 이름
@@ -85,3 +88,65 @@ class PetStack extends StatelessWidget {
);
}
}
/// 누구의 일정·다이어리인지 고르는 알약 (시안 .rup-petchip)
class TargetChip extends StatelessWidget {
const TargetChip({
super.key,
required this.targetId,
required this.name,
required this.selected,
required this.onTap,
this.showCheck = false,
});
final String targetId;
final String name;
final bool selected;
final VoidCallback onTap;
/// 고르면 이름 옆에 ✓ (다이어리 대상)
final bool showCheck;
@override
Widget build(BuildContext context) {
return Semantics(
button: true,
selected: selected,
label: name,
excludeSemantics: true,
child: GestureDetector(
onTap: onTap,
child: AnimatedContainer(
duration: AppDurations.fast,
padding: const EdgeInsets.fromLTRB(6, 6, 14, 6),
decoration: BoxDecoration(
color: selected ? AppColors.surface2 : AppColors.surface,
borderRadius: BorderRadius.circular(AppRadius.pill),
border: Border.all(color: selected ? AppColors.primary100 : AppColors.line, width: 1.5),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
TargetAvatar(targetId: targetId, size: 26),
const SizedBox(width: AppSpacing.sm),
Text(
name,
style: Theme.of(context).textTheme.bodyMedium!.copyWith(
fontSize: 14,
fontWeight: FontWeight.w600,
color: AppColors.ink,
height: AppTypography.normalHeight,
),
),
if (showCheck && selected) ...[
const SizedBox(width: AppSpacing.sm),
const RupIcon(RupIconName.check, size: 14, stroke: 2.6, color: AppColors.primary600),
],
],
),
),
),
);
}
}
@@ -5,8 +5,8 @@ import 'package:tma_app/core/utils/date_format.dart';
import 'package:tma_app/core/utils/hour_minute.dart';
import 'package:tma_app/features/schedule/domain/event_category.dart';
/// 일정 대상 중 "나(보호자 본인)" — 반려동물 id 와 같은 자리에 둔다 (시안 SELF_PET)
const String kSelfTargetId = 'self';
export 'package:tma_app/features/pet/domain/pet_target.dart' show kSelfTargetId;
/// 날짜·id 를 뺀 일정 내용 — 반복 규칙의 원본으로도 쓴다
class EventDetails {
@@ -10,6 +10,7 @@ import 'package:tma_app/core/utils/date_format.dart';
import 'package:tma_app/core/utils/hour_minute.dart';
import 'package:tma_app/features/pet/domain/pet.dart';
import 'package:tma_app/features/pet/presentation/providers/pet_providers.dart';
import 'package:tma_app/features/pet/presentation/widgets/target_avatar.dart';
import 'package:tma_app/features/pet_card/presentation/widgets/color_pick_sheet.dart';
import 'package:tma_app/features/schedule/domain/care_preset.dart';
import 'package:tma_app/features/schedule/domain/event_category.dart';
@@ -18,7 +19,6 @@ import 'package:tma_app/features/schedule/domain/schedule_event.dart';
import 'package:tma_app/features/schedule/presentation/providers/schedule_providers.dart';
import 'package:tma_app/features/schedule/presentation/widgets/event_editor_parts.dart';
import 'package:tma_app/features/schedule/presentation/widgets/event_tile.dart';
import 'package:tma_app/features/schedule/presentation/widgets/target_avatar.dart';
import 'package:tma_app/shared/widgets/date_picker_sheet.dart';
import 'package:tma_app/shared/widgets/heart_toggle.dart';
import 'package:tma_app/shared/widgets/icons/rup_icon.dart';
@@ -7,7 +7,6 @@ import 'package:tma_app/core/theme/app_typography.dart';
import 'package:tma_app/core/utils/color_utils.dart';
import 'package:tma_app/features/schedule/domain/event_category.dart';
import 'package:tma_app/features/schedule/domain/repeat_rule.dart';
import 'package:tma_app/features/schedule/presentation/widgets/target_avatar.dart';
import 'package:tma_app/shared/widgets/dashed_border_box.dart';
import 'package:tma_app/shared/widgets/heart_toggle.dart';
import 'package:tma_app/shared/widgets/icons/rup_icon.dart';
@@ -59,46 +58,6 @@ class EditorField extends StatelessWidget {
}
}
/// 누구의 일정인지 고르는 알약 (시안 .rup-petchip)
class TargetChip extends StatelessWidget {
const TargetChip({super.key, required this.targetId, required this.name, required this.selected, required this.onTap});
final String targetId;
final String name;
final bool selected;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return Semantics(
button: true,
selected: selected,
label: name,
excludeSemantics: true,
child: GestureDetector(
onTap: onTap,
child: AnimatedContainer(
duration: AppDurations.fast,
padding: const EdgeInsets.fromLTRB(6, 6, 14, 6),
decoration: BoxDecoration(
color: selected ? AppColors.surface2 : AppColors.surface,
borderRadius: BorderRadius.circular(AppRadius.pill),
border: Border.all(color: selected ? AppColors.primary100 : AppColors.line, width: 1.5),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
TargetAvatar(targetId: targetId, size: 26),
const SizedBox(width: AppSpacing.sm),
Text(name, style: _ui(context, 14, FontWeight.w600, AppColors.ink)),
],
),
),
),
);
}
}
/// 예약 일정 분류 네 칸 — 병원·예방접종·미용·기타
class CategoryGrid extends StatelessWidget {
const CategoryGrid({super.key, required this.selected, required this.onSelect});
@@ -3,9 +3,9 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:tma_app/core/constants/app_colors.dart';
import 'package:tma_app/core/constants/app_sizes.dart';
import 'package:tma_app/core/theme/app_typography.dart';
import 'package:tma_app/features/pet/presentation/widgets/target_avatar.dart';
import 'package:tma_app/features/schedule/domain/event_category.dart';
import 'package:tma_app/features/schedule/domain/schedule_event.dart';
import 'package:tma_app/features/schedule/presentation/widgets/target_avatar.dart';
import 'package:tma_app/shared/widgets/icons/rup_icon.dart';
/// 하루 종일 일정의 시간 칸 표기