From 76e9fc1af1a10633954bdc1ab99bd8e580337b3d Mon Sep 17 00:00:00 2001 From: TtiPo Date: Mon, 28 Sep 2026 22:57:30 +0900 Subject: [PATCH] =?UTF-8?q?refactor(flutter):=20=EB=8C=80=EC=83=81=20?= =?UTF-8?q?=EC=95=84=EB=B0=94=ED=83=80=C2=B7=EC=B9=A9=EC=9D=84=20=EB=B0=98?= =?UTF-8?q?=EB=A0=A4=EB=8F=99=EB=AC=BC=20=EA=B3=B5=EC=9A=A9=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EC=98=AE=EA=B8=B0=EA=B3=A0=20=EC=82=AC=EC=A7=84=20?= =?UTF-8?q?=EC=B9=B8=C2=B7=ED=86=A0=EC=8A=A4=ED=8A=B8=20=EB=B3=B4=EC=99=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 다이어리도 "누구의 기록인지"(아이·나)를 고르므로 일정 전용이던 부품을 공용으로 옮긴다. - TargetAvatar·PetStack·TargetChip → features/pet (칩에 선택 ✓ 표시 추가), "나" id 는 pet_target.dart - 사진 칸에 높이·모서리·빈 칸 문구 지정, 앨범에서 골라 저장하는 도우미 - 여러 줄 입력칸 최소 높이 지정, "오늘·어제·N일 전" 날짜 표기 - 긴 토스트 문구가 화면 밖으로 넘치던 문제 — 넘치면 줄을 바꾼다 Co-Authored-By: Claude Opus 5.5 (1M context) --- flutter/lib/core/utils/date_format.dart | 11 +++ .../widgets/home_schedule_section.dart | 2 +- .../features/pet/data/pet_photo_store.dart | 16 +++++ .../lib/features/pet/domain/pet_target.dart | 2 + .../presentation/widgets/pet_photo_slot.dart | 54 +++++++++++---- .../presentation/widgets/target_avatar.dart | 67 ++++++++++++++++++- .../schedule/domain/schedule_event.dart | 4 +- .../presentation/pages/event_editor_page.dart | 2 +- .../widgets/event_editor_parts.dart | 41 ------------ .../presentation/widgets/event_tile.dart | 2 +- .../lib/shared/widgets/rup_form_fields.dart | 6 +- flutter/lib/shared/widgets/rup_toast.dart | 17 +++-- 12 files changed, 155 insertions(+), 69 deletions(-) create mode 100644 flutter/lib/features/pet/domain/pet_target.dart rename flutter/lib/features/{schedule => pet}/presentation/widgets/target_avatar.dart (59%) diff --git a/flutter/lib/core/utils/date_format.dart b/flutter/lib/core/utils/date_format.dart index 31cd800..73983d2 100644 --- a/flutter/lib/core/utils/date_format.dart +++ b/flutter/lib/core/utils/date_format.dart @@ -31,6 +31,17 @@ DateTime parseIsoDate(String iso) { return DateTime(parts[0], parts[1], parts[2]); } +/// 기록한 날 표기 — 오늘 · 어제 · N일 전(일주일 안) · 그 밖은 2026.09.14 (시안 relWhen) +String relativeDayLabel(DateTime date, DateTime today) { + final diff = DateTime.utc(today.year, today.month, today.day) + .difference(DateTime.utc(date.year, date.month, date.day)) + .inDays; + if (diff == 0) return '오늘'; + if (diff == 1) return '어제'; + if (diff > 1 && diff < 7) return '$diff일 전'; + return formatYmd(date); +} + /// 주소 등 바깥에서 받은 값 — 형식이 틀리면 null DateTime? parseIsoDateOrNull(String? iso) { final m = RegExp(r'^(\d{4})-(\d{2})-(\d{2})$').firstMatch(iso ?? ''); 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 6fe2db3..a9b83b9 100644 --- a/flutter/lib/features/home/presentation/widgets/home_schedule_section.dart +++ b/flutter/lib/features/home/presentation/widgets/home_schedule_section.dart @@ -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'; diff --git a/flutter/lib/features/pet/data/pet_photo_store.dart b/flutter/lib/features/pet/data/pet_photo_store.dart index 0c2a751..38893bb 100644 --- a/flutter/lib/features/pet/data/pet_photo_store.dart +++ b/flutter/lib/features/pet/data/pet_photo_store.dart @@ -38,3 +38,19 @@ class PetPhotoStore { } final petPhotoStoreProvider = Provider((ref) => const PetPhotoStore()); + +/// 사진을 고를 때 줄이는 크기 — 원본 그대로 두면 저장 공간을 많이 쓴다 +const double kPhotoMaxSide = 1600; +const int kPhotoQuality = 85; + +/// 앨범에서 사진 하나를 골라 앱 폴더에 저장하고 경로를 돌려준다 — 고르지 않으면 null +Future 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); +} diff --git a/flutter/lib/features/pet/domain/pet_target.dart b/flutter/lib/features/pet/domain/pet_target.dart new file mode 100644 index 0000000..ea0384b --- /dev/null +++ b/flutter/lib/features/pet/domain/pet_target.dart @@ -0,0 +1,2 @@ +/// 일정·다이어리의 대상 중 "나(보호자 본인)" — 반려동물 id 와 같은 자리에 둔다 (시안 SELF_PET) +const String kSelfTargetId = 'self'; diff --git a/flutter/lib/features/pet/presentation/widgets/pet_photo_slot.dart b/flutter/lib/features/pet/presentation/widgets/pet_photo_slot.dart index 0eeb428..41c395b 100644 --- a/flutter/lib/features/pet/presentation/widgets/pet_photo_slot.dart +++ b/flutter/lib/features/pet/presentation/widgets/pet_photo_slot.dart @@ -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), ), // 사진을 바꿀 수 있다는 표시 diff --git a/flutter/lib/features/schedule/presentation/widgets/target_avatar.dart b/flutter/lib/features/pet/presentation/widgets/target_avatar.dart similarity index 59% rename from flutter/lib/features/schedule/presentation/widgets/target_avatar.dart rename to flutter/lib/features/pet/presentation/widgets/target_avatar.dart index 4109208..2b8d902 100644 --- a/flutter/lib/features/schedule/presentation/widgets/target_avatar.dart +++ b/flutter/lib/features/pet/presentation/widgets/target_avatar.dart @@ -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), + ], + ], + ), + ), + ), + ); + } +} diff --git a/flutter/lib/features/schedule/domain/schedule_event.dart b/flutter/lib/features/schedule/domain/schedule_event.dart index d136ce4..ec69dfe 100644 --- a/flutter/lib/features/schedule/domain/schedule_event.dart +++ b/flutter/lib/features/schedule/domain/schedule_event.dart @@ -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 { diff --git a/flutter/lib/features/schedule/presentation/pages/event_editor_page.dart b/flutter/lib/features/schedule/presentation/pages/event_editor_page.dart index 68e655a..fa794e6 100644 --- a/flutter/lib/features/schedule/presentation/pages/event_editor_page.dart +++ b/flutter/lib/features/schedule/presentation/pages/event_editor_page.dart @@ -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'; diff --git a/flutter/lib/features/schedule/presentation/widgets/event_editor_parts.dart b/flutter/lib/features/schedule/presentation/widgets/event_editor_parts.dart index a2f7a6a..4b8be06 100644 --- a/flutter/lib/features/schedule/presentation/widgets/event_editor_parts.dart +++ b/flutter/lib/features/schedule/presentation/widgets/event_editor_parts.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}); diff --git a/flutter/lib/features/schedule/presentation/widgets/event_tile.dart b/flutter/lib/features/schedule/presentation/widgets/event_tile.dart index 1445f3a..2c4e882 100644 --- a/flutter/lib/features/schedule/presentation/widgets/event_tile.dart +++ b/flutter/lib/features/schedule/presentation/widgets/event_tile.dart @@ -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'; /// 하루 종일 일정의 시간 칸 표기 diff --git a/flutter/lib/shared/widgets/rup_form_fields.dart b/flutter/lib/shared/widgets/rup_form_fields.dart index b8c4a14..34e05e0 100644 --- a/flutter/lib/shared/widgets/rup_form_fields.dart +++ b/flutter/lib/shared/widgets/rup_form_fields.dart @@ -113,6 +113,7 @@ class RupTextField extends StatefulWidget { this.onSubmitted, this.textInputAction, this.multiline = false, + this.minHeight = _kTextareaMinHeight, this.autofocus = false, this.maxLength, this.prefix, @@ -131,6 +132,9 @@ class RupTextField extends StatefulWidget { /// true 면 여러 줄 메모칸(.rup-textarea, 최소 높이 84) final bool multiline; + + /// 여러 줄 메모칸의 최소 높이 — 다이어리 글은 120 + final double minHeight; final bool autofocus; final int? maxLength; /// 입력 앞뒤에 붙는 아이콘·버튼 (검색 돋보기, 지우기 등) @@ -193,7 +197,7 @@ class _RupTextFieldState extends State { onTap: _focus.requestFocus, child: Container( constraints: widget.multiline - ? const BoxConstraints(minHeight: _kTextareaMinHeight) + ? BoxConstraints(minHeight: widget.minHeight) : null, padding: _kInputPadding, decoration: _inputDecoration(error: widget.hasError, focused: _focus.hasFocus), diff --git a/flutter/lib/shared/widgets/rup_toast.dart b/flutter/lib/shared/widgets/rup_toast.dart index 033d876..416de97 100644 --- a/flutter/lib/shared/widgets/rup_toast.dart +++ b/flutter/lib/shared/widgets/rup_toast.dart @@ -107,13 +107,16 @@ class _ToastBubble extends StatelessWidget { children: [ RupIcon(message.icon, size: 16, stroke: 2.2, color: AppColors.ink2), const SizedBox(width: 8), - Text( - message.text, - style: Theme.of(context).textTheme.bodyMedium!.copyWith( - color: AppColors.ink2, - fontWeight: FontWeight.w600, - height: 1.2, - ), + // 시안은 한 줄(nowrap)이지만, 화면보다 긴 안내는 넘치지 않게 줄을 바꾼다 + Flexible( + child: Text( + message.text, + style: Theme.of(context).textTheme.bodyMedium!.copyWith( + color: AppColors.ink2, + fontWeight: FontWeight.w600, + height: 1.2, + ), + ), ), ], ),