generated from ttipo/project-base
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:
@@ -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 ?? '');
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
// 사진을 바꿀 수 있다는 표시
|
||||
|
||||
+66
-1
@@ -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';
|
||||
|
||||
/// 하루 종일 일정의 시간 칸 표기
|
||||
|
||||
@@ -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<RupTextField> {
|
||||
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),
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user