diff --git a/flutter/lib/features/diary/presentation/widgets/diary_shelf.dart b/flutter/lib/features/diary/presentation/widgets/diary_shelf.dart index 9fc5949..8f243db 100644 --- a/flutter/lib/features/diary/presentation/widgets/diary_shelf.dart +++ b/flutter/lib/features/diary/presentation/widgets/diary_shelf.dart @@ -16,6 +16,7 @@ import 'package:tma_app/features/diary/presentation/widgets/diary_cover.dart'; import 'package:tma_app/features/diary/presentation/widgets/diary_sheets.dart'; import 'package:tma_app/features/pet/presentation/widgets/pet_photo_image.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/shared/widgets/rup_card.dart'; import 'package:tma_app/shared/widgets/section_title.dart'; @@ -88,7 +89,7 @@ class HomeDiaryGrid extends ConsumerWidget { // 추억 보관함으로 간 다이어리의 기록은 홈에 올리지 않는다 final shelf = {for (final d in ref.watch(shelfDiariesProvider)) d.id}; final latest = book.latest(_kHomeDiaryCount, diaryIds: shelf); - if (latest.isEmpty) return const RupCard(child: EmptyMessage('아직 작성한 다이어리가 없어요')); + if (latest.isEmpty) return const RupCard(child: EmptyMessage('아직 작성한 다이어리가 없어요', padding: 28)); final today = dateOnly(DateTime.now()); return Row( @@ -107,7 +108,7 @@ class HomeDiaryGrid extends ConsumerWidget { } } -class _HomeDiaryTile extends StatelessWidget { +class _HomeDiaryTile extends ConsumerWidget { const _HomeDiaryTile({required this.post, required this.book, required this.names, required this.today}); final DiaryPost post; @@ -116,7 +117,7 @@ class _HomeDiaryTile extends StatelessWidget { final DateTime today; @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { final diary = book.diary(post.diaryId); // 누구의 기록인지 — 다이어리의 첫 아이, 없으면 다이어리 이름 final who = diary == null ? '' : (diary.targetIds.map((id) => names[id]).nonNulls.firstOrNull ?? diary.name); @@ -129,7 +130,10 @@ class _HomeDiaryTile extends StatelessWidget { label: '$who ${relativeDayLabel(post.date, today)} 기록', excludeSemantics: true, child: GestureDetector( - onTap: () => unawaited(context.push(AppRoutes.diary(post.diaryId))), + onTap: () { + ref.read(recordsTabProvider.notifier).select(RecordsTab.diary); + context.go(AppRoutes.records); + }, child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ diff --git a/flutter/lib/features/home/presentation/pages/home_page.dart b/flutter/lib/features/home/presentation/pages/home_page.dart index bfb9ad8..652060a 100644 --- a/flutter/lib/features/home/presentation/pages/home_page.dart +++ b/flutter/lib/features/home/presentation/pages/home_page.dart @@ -55,6 +55,7 @@ class HomePage extends ConsumerWidget { title: '우리 아이', sub: true, trailing: '아이 상세보기', + smallTrailing: true, onTrailingTap: () => context.go(AppRoutes.myPets), ), ), diff --git a/flutter/lib/features/home/presentation/widgets/home_pets_section.dart b/flutter/lib/features/home/presentation/widgets/home_pets_section.dart index a55b68f..4a0e6f9 100644 --- a/flutter/lib/features/home/presentation/widgets/home_pets_section.dart +++ b/flutter/lib/features/home/presentation/widgets/home_pets_section.dart @@ -56,6 +56,8 @@ class _HomePetsSectionState extends ConsumerState { pets: pets, selectedId: selected?.id, onSelect: (id) => ref.read(selectedPetIdProvider.notifier).select(id), + // 꾹 눌러 끌어서 우리 아이 순서를 바꾼다 + onReorder: (ids) => unawaited(ref.read(petListProvider.notifier).reorder(ids)), ), if (selected != null) ...[ const SizedBox(height: 20), 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 a9b83b9..f134abc 100644 --- a/flutter/lib/features/home/presentation/widgets/home_schedule_section.dart +++ b/flutter/lib/features/home/presentation/widgets/home_schedule_section.dart @@ -99,9 +99,9 @@ class HomeScheduleSection extends ConsumerWidget { children: [ _TodayProgress(done: doneCount, total: tasks.length), if (tasks.isEmpty) - const EmptyMessage('오늘은 등록된 일정이 없어요') + const EmptyMessage('오늘은 등록된 일정이 없어요', padding: 28) else if (!review && pending.isEmpty) - const EmptyMessage('남은 오늘 일정이 없어요'), + const EmptyMessage('남은 오늘 일정이 없어요', padding: 28), for (final (i, t) in visible.indexed) _TaskRow( event: t, @@ -122,7 +122,7 @@ class HomeScheduleSection extends ConsumerWidget { const SizedBox(height: 10), RupCard( child: upcoming.isEmpty - ? const EmptyMessage('예약된 일정이 없어요') + ? const EmptyMessage('예약된 일정이 없어요', padding: 28) : Column( children: [ for (final (i, e) in upcoming.indexed) @@ -140,6 +140,9 @@ class HomeScheduleSection extends ConsumerWidget { } } +/// 아바타 묶음 아래 글자 여백 (시안 inline-flex 기준선) +const double _kStackDescent = 3.5; + /// 바로 다음 할 일 — 연한 주황 테두리가 천천히 돈다 (.rup-ring-card) class _NextTaskCard extends StatelessWidget { const _NextTaskCard({required this.event, required this.onTap}); @@ -161,7 +164,11 @@ class _NextTaskCard extends StatelessWidget { padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 9), child: Row( children: [ - PetStack(targetIds: d.targetIds, size: 30), + // 시안의 아바타 묶음은 inline-flex 라 글자 아래 여백(3.5)만큼 자리가 더 생긴다 + Padding( + padding: const EdgeInsets.only(bottom: _kStackDescent), + child: PetStack(targetIds: d.targetIds, size: 30), + ), const SizedBox(width: 10), Expanded( child: Text( @@ -367,16 +374,19 @@ class _TodayProgress extends StatelessWidget { Text('오늘 할 일', style: label), const SizedBox(width: 11), Expanded( - child: ClipRRect( - borderRadius: BorderRadius.circular(6), - child: TweenAnimationBuilder( - tween: Tween(end: total == 0 ? 0 : done / total), - duration: const Duration(milliseconds: 300), - builder: (context, v, _) => LinearProgressIndicator( - value: v, - minHeight: 6, - backgroundColor: AppColors.line2, - color: AppColors.primary, + // 시안 .rup-prog__bar — 채운 막대도 끝이 둥글다 + child: TweenAnimationBuilder( + tween: Tween(end: total == 0 ? 0 : done / total), + duration: const Duration(milliseconds: 300), + builder: (context, v, _) => Container( + height: 6, + decoration: BoxDecoration(color: AppColors.line2, borderRadius: BorderRadius.circular(6)), + alignment: Alignment.centerLeft, + child: FractionallySizedBox( + widthFactor: v, + child: Container( + decoration: BoxDecoration(color: AppColors.primary, borderRadius: BorderRadius.circular(6)), + ), ), ), ), @@ -444,8 +454,6 @@ class _TaskRow extends ConsumerWidget { children: [ Text( d.title, - maxLines: 1, - overflow: TextOverflow.ellipsis, style: textTheme.titleSmall!.copyWith( fontWeight: FontWeight.w600, height: _kNormal, @@ -456,8 +464,6 @@ class _TaskRow extends ConsumerWidget { ), Text( time, - maxLines: 1, - overflow: TextOverflow.ellipsis, style: textTheme.bodySmall!.copyWith(color: AppColors.muted, height: _kNormal), ), ], @@ -570,8 +576,6 @@ class _UpcomingRow extends StatelessWidget { children: [ Text( d.title, - maxLines: 1, - overflow: TextOverflow.ellipsis, style: textTheme.titleSmall!.copyWith(fontWeight: FontWeight.w600, height: _kNormal), ), EventMeta(details: d, avatarSize: 14, gap: 4), diff --git a/flutter/lib/features/pet/data/repositories/pet_repository.dart b/flutter/lib/features/pet/data/repositories/pet_repository.dart index 13ee5eb..88af03e 100644 --- a/flutter/lib/features/pet/data/repositories/pet_repository.dart +++ b/flutter/lib/features/pet/data/repositories/pet_repository.dart @@ -22,6 +22,9 @@ abstract interface class PetRepository { /// 보관함에서 다시 불러온다 Future restore(String id); + + /// 순서 바꾸기 — [ids] 순서로 두고, 빠진 아이(추억 보관함 등)는 뒤에 그대로 둔다 + Future> reorder(List ids); } const String _kPetsKey = 'pets.v1'; @@ -75,6 +78,13 @@ class LocalPetRepository implements PetRepository { @override Future restore(String id) => _setArchive(id, null); + @override + Future> reorder(List ids) async { + final pets = reorderPets(_read(), ids); + await _write(pets); + return pets; + } + Future _setArchive(String id, PetArchive? archive) async { final pets = _read(); final pet = pets.firstWhere((p) => p.id == id).withArchive(archive); @@ -88,6 +98,15 @@ class LocalPetRepository implements PetRepository { } } +/// [ids] 순서로 줄 세운 목록 — 빠진 아이는 원래 순서대로 뒤에 붙인다 (시안 reorderPets) +List reorderPets(List pets, List ids) { + final byId = {for (final p in pets) p.id: p}; + return [ + for (final id in ids) ?byId[id], + for (final p in pets) if (!ids.contains(p.id)) p, + ]; +} + final petRepositoryProvider = Provider((ref) { return LocalPetRepository(ref.watch(sharedPreferencesProvider)); }); diff --git a/flutter/lib/features/pet/presentation/providers/pet_providers.dart b/flutter/lib/features/pet/presentation/providers/pet_providers.dart index 6065683..74662a3 100644 --- a/flutter/lib/features/pet/presentation/providers/pet_providers.dart +++ b/flutter/lib/features/pet/presentation/providers/pet_providers.dart @@ -33,6 +33,12 @@ class PetListNotifier extends AsyncNotifier> { Future archivePet(String id, PetArchive archive) async => _replace(await _repo.archive(id, archive)); + /// 우리 아이 순서 바꾸기 — 화면에는 바로 반영하고 저장한다 + Future reorder(List ids) async { + state = AsyncData(reorderPets(state.value ?? const [], ids)); + await _repo.reorder(ids); + } + Future restorePet(String id) async => _replace(await _repo.restore(id)); void _replace(Pet pet) { @@ -54,9 +60,8 @@ extension PetListX on List { /// 함께 지내는 아이 — 추억 보관함으로 옮긴 아이는 빠진다 List get active => where((p) => !p.isArchived).toList(); - /// 추억 보관함의 아이 — 최근에 보관한 아이가 위로 - List get archived => - where((p) => p.isArchived).toList()..sort((a, b) => b.archive!.archivedAt.compareTo(a.archive!.archivedAt)); + /// 추억 보관함의 아이 — 등록 순서대로 (시안) + List get archived => where((p) => p.isArchived).toList(); /// 보관된 아이 id — 다이어리를 가를 때 Set get archivedIds => {for (final p in this) if (p.isArchived) p.id}; diff --git a/flutter/lib/features/pet/presentation/widgets/pet_picker.dart b/flutter/lib/features/pet/presentation/widgets/pet_picker.dart index 936924d..72092ce 100644 --- a/flutter/lib/features/pet/presentation/widgets/pet_picker.dart +++ b/flutter/lib/features/pet/presentation/widgets/pet_picker.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:tma_app/core/constants/app_colors.dart'; import 'package:tma_app/core/constants/app_sizes.dart'; import 'package:tma_app/features/pet/domain/pet.dart'; @@ -9,65 +10,187 @@ const double _kRingSize = 58; const double _kAvatarSize = 46; const double _kRingBorder = 2.5; -/// 가로로 넘기는 아이 목록 (시안 .rup-petpick) — 고른 아이는 주황 테두리 -class PetPicker extends StatelessWidget { - const PetPicker({super.key, required this.pets, required this.selectedId, required this.onSelect}); +/// 꾹 눌러 순서 바꾸기 — 시안: 320ms 누르면 들어 올리고, 1.12 배로 손가락을 따라온다 +const Duration _kHoldDelay = Duration(milliseconds: 320); +const double _kLiftScale = 1.12; + +/// 들어 올린 아이 그림자 (시안 drop-shadow(0 8px 14px rgba(0,0,0,.22))) +const List _kLiftShadow = [BoxShadow(color: Color(0x38000000), offset: Offset(0, 8), blurRadius: 14)]; +const List _kLiftTextShadow = [Shadow(color: Color(0x38000000), offset: Offset(0, 8), blurRadius: 14)]; + +/// 가로로 넘기는 아이 목록 (시안 .rup-petpick) — 고른 아이는 주황 테두리. +/// [onReorder] 가 있으면 꾹 눌러 끌어서 순서를 바꾼다 (홈 우리 아이). +class PetPicker extends StatefulWidget { + const PetPicker({ + super.key, + required this.pets, + required this.selectedId, + required this.onSelect, + this.onReorder, + }); final List pets; final String? selectedId; final ValueChanged onSelect; + /// 끌어서 바꾼 순서 (아이 id 목록) + final ValueChanged>? onReorder; + + @override + State createState() => _PetPickerState(); +} + +class _PetPickerState extends State { + /// 끄는 동안의 임시 순서 — 평소에는 null + List? _order; + String? _dragId; + + List get _shown { + final order = _order; + if (order == null) return widget.pets; + final byId = {for (final p in widget.pets) p.id: p}; + return [for (final id in order) ?byId[id]]; + } + + void _start(String id) { + HapticFeedback.selectionClick(); + setState(() { + _order = [for (final p in widget.pets) p.id]; + _dragId = id; + }); + } + + /// 끌고 있는 아이가 다른 아이 위로 가면 그 자리로 옮긴다 + void _moveOver(String overId) { + final order = _order; + final dragId = _dragId; + if (order == null || dragId == null || overId == dragId) return; + final to = order.indexOf(overId); + setState(() { + order + ..remove(dragId) + ..insert(to, dragId); + }); + } + + void _end() { + final order = _order; + final before = [for (final p in widget.pets) p.id]; + setState(() { + _order = null; + _dragId = null; + }); + if (order != null && order.join() != before.join()) widget.onReorder?.call(order); + } + @override Widget build(BuildContext context) { - final textTheme = Theme.of(context).textTheme; + final reorderable = widget.onReorder != null && widget.pets.length > 1; return SingleChildScrollView( scrollDirection: Axis.horizontal, - clipBehavior: Clip.none, + // 시안: 목록 칸(화면 여백 안쪽)에서 잘린다 + clipBehavior: Clip.hardEdge, padding: const EdgeInsets.fromLTRB(2, AppSpacing.xs, 2, AppSpacing.sm), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ - for (final (i, pet) in pets.indexed) ...[ + for (final (i, pet) in _shown.indexed) ...[ if (i > 0) const SizedBox(width: 14), - Semantics( - button: true, - selected: pet.id == selectedId, - label: pet.name, - excludeSemantics: true, - child: GestureDetector( - onTap: () => onSelect(pet.id), - child: Column( - children: [ - AnimatedContainer( - duration: AppDurations.fast, - width: _kRingSize, - height: _kRingSize, - decoration: BoxDecoration( - shape: BoxShape.circle, - border: Border.all( - color: pet.id == selectedId ? AppColors.primary : Colors.transparent, - width: _kRingBorder, - ), - ), - alignment: Alignment.center, - child: PetAvatar(pet: pet, size: _kAvatarSize), - ), - const SizedBox(height: 7), - Text( - pet.name, - style: textTheme.bodySmall!.copyWith( - fontSize: 12.5, - fontWeight: pet.id == selectedId ? FontWeight.w700 : FontWeight.w600, - color: pet.id == selectedId ? AppColors.primary600 : AppColors.muted, - ), - ), - ], - ), - ), - ), + if (reorderable) _draggable(pet) else _chip(pet), ], ], ), ); } + + Widget _chip(Pet pet, {bool lifted = false}) { + return _PetChip( + pet: pet, + selected: pet.id == widget.selectedId, + lifted: lifted, + onTap: () => widget.onSelect(pet.id), + ); + } + + Widget _draggable(Pet pet) { + return DragTarget( + onWillAcceptWithDetails: (details) { + _moveOver(pet.id); + return true; + }, + builder: (context, candidates, rejected) => LongPressDraggable( + data: pet.id, + delay: _kHoldDelay, + hapticFeedbackOnStart: false, + // 들어 올린 아이는 손가락 한가운데에 붙는다 + dragAnchorStrategy: pointerDragAnchorStrategy, + feedback: FractionalTranslation( + translation: const Offset(-.5, -.5), + child: Material( + type: MaterialType.transparency, + child: Transform.scale(scale: _kLiftScale, child: _chip(pet, lifted: true)), + ), + ), + // 빈 자리는 남겨 둔다 — 다른 아이가 그 자리로 비켜 준다 + childWhenDragging: Opacity(opacity: 0, child: _chip(pet)), + onDragStarted: () => _start(pet.id), + onDragEnd: (_) => _end(), + child: _chip(pet), + ), + ); + } +} + +class _PetChip extends StatelessWidget { + const _PetChip({required this.pet, required this.selected, required this.onTap, this.lifted = false}); + + final Pet pet; + final bool selected; + final bool lifted; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final textTheme = Theme.of(context).textTheme; + return Semantics( + button: true, + selected: selected, + label: pet.name, + excludeSemantics: true, + child: GestureDetector( + onTap: onTap, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + AnimatedContainer( + duration: AppDurations.fast, + width: _kRingSize, + height: _kRingSize, + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all(color: selected ? AppColors.primary : Colors.transparent, width: _kRingBorder), + // 시안 drop-shadow 는 그려진 모양을 따른다 — 고리는 고른 아이만 보인다 + boxShadow: lifted && selected ? _kLiftShadow : null, + ), + alignment: Alignment.center, + child: DecoratedBox( + decoration: BoxDecoration(shape: BoxShape.circle, boxShadow: lifted ? _kLiftShadow : null), + child: PetAvatar(pet: pet, size: _kAvatarSize), + ), + ), + const SizedBox(height: 7), + Text( + pet.name, + style: textTheme.bodySmall!.copyWith( + fontSize: 12.5, + fontWeight: selected ? FontWeight.w700 : FontWeight.w600, + color: selected ? AppColors.primary600 : AppColors.muted, + shadows: lifted ? _kLiftTextShadow : null, + ), + ), + ], + ), + ), + ); + } } diff --git a/flutter/lib/features/schedule/domain/repeat_rule.dart b/flutter/lib/features/schedule/domain/repeat_rule.dart index ff69dc4..d741e3a 100644 --- a/flutter/lib/features/schedule/domain/repeat_rule.dart +++ b/flutter/lib/features/schedule/domain/repeat_rule.dart @@ -15,6 +15,9 @@ enum RepeatUnit { final String label; } +/// 반복을 켰을 때 — 매일. 직접 설정을 열면 2주마다부터 (시안 repeatUnit 'week' · repeatN 2) +const RepeatSpec kInitialRepeat = RepeatSpec(RepeatPattern.daily, n: 2); + /// 반복 주기 — "2주마다" class RepeatSpec { const RepeatSpec(this.mode, {this.unit = RepeatUnit.week, this.n = 1}); 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 6797f11..38e3007 100644 --- a/flutter/lib/features/schedule/presentation/pages/event_editor_page.dart +++ b/flutter/lib/features/schedule/presentation/pages/event_editor_page.dart @@ -158,10 +158,12 @@ class _EventEditorState extends ConsumerState<_EventEditor> { // ── 일반 일정 목록 ──────────────────────────────────── void _pickCare(CarePreset p) { - // 이름 칸의 변경 알림이 다시 그려 준다 - _kind = EventKind.general; - _category = p.category; - _title.text = p.title; + // 이름이 같으면 이름 칸이 알리지 않으므로 직접 다시 그린다 (예약 → 같은 일반 일정으로 돌아올 때) + setState(() { + _kind = EventKind.general; + _category = p.category; + _title.text = p.title; + }); } Future _removeCare(CarePreset p) async { @@ -176,8 +178,10 @@ class _EventEditorState extends ConsumerState<_EventEditor> { if (v.isEmpty) return; await ref.read(carePresetsProvider.notifier).add(CarePreset(v, _category)); if (!mounted) return; - _kind = EventKind.general; - _title.text = v; + setState(() { + _kind = EventKind.general; + _title.text = v; + }); } // ── 시간 ───────────────────────────────────────────── @@ -251,7 +255,8 @@ class _EventEditorState extends ConsumerState<_EventEditor> { // ── 저장 ───────────────────────────────────────────── EventDetails _details({HourMinute? startOverride, bool dropEnd = false}) { - final start = _allDay ? null : (startOverride ?? _start); + // 하루 여러 번(회차 시각)은 '하루 종일' 과 상관없이 그 시각으로 만든다 (시안 doseTimes) + final start = startOverride ?? (_allDay ? null : _start); final place = _place.text.trim(); return EventDetails( title: _title.text.trim(), @@ -284,7 +289,8 @@ class _EventEditorState extends ConsumerState<_EventEditor> { if (_saving) return; final toast = ref.read(toastProvider.notifier); if (_title.text.trim().isEmpty) { - toast.show('일정 이름을 입력해주세요', icon: RupIconName.alert); + // 시안 showToast 기본 아이콘(check) + toast.show('일정 이름을 입력해주세요'); return; } final notifier = ref.read(scheduleProvider.notifier); @@ -324,13 +330,10 @@ class _EventEditorState extends ConsumerState<_EventEditor> { Future _delete() async { final editing = widget.editing; if (editing == null) return; - final ok = await showRupConfirm( + final ok = await showRupRemoveConfirm( context, title: '일정을 제거할까요?', message: editing.isRepeat ? '반복으로 등록된 일정 전체가 함께 제거돼요.' : '제거한 일정은 되돌릴 수 없어요.', - confirmLabel: '제거하기', - destructive: true, - layout: RupConfirmLayout.row, ); if (!ok || !mounted) return; // 먼저 화면을 닫아 지워진 일정을 그리지 않게 한다 @@ -435,11 +438,11 @@ class _EventEditorState extends ConsumerState<_EventEditor> { subtitle: _repeat == null ? '반복되는 일정에 활용해보세요' : '${_repeat!.label} 반복', on: _repeat != null, onChanged: (v) => setState(() { - _repeat = v ? const RepeatSpec(RepeatPattern.daily) : null; + _repeat = v ? kInitialRepeat : null; _customCycle = false; }), child: RepeatOptions( - spec: _repeat ?? const RepeatSpec(RepeatPattern.daily), + spec: _repeat ?? kInitialRepeat, custom: _customCycle, onSpec: (s) => setState(() => _repeat = s), onCustom: (v) => setState(() => _customCycle = v), @@ -480,6 +483,7 @@ class _EventEditorState extends ConsumerState<_EventEditor> { large: true, thin: true, dimmed: !hasTitle, + dimmedOpacity: .45, onPressed: () => unawaited(_submit()), ), if (_editing) ...[ @@ -598,7 +602,15 @@ class _EventEditorState extends ConsumerState<_EventEditor> { children: [ const RupIcon(RupIconName.calendar, size: 15, stroke: 2, color: AppColors.primary), const SizedBox(width: 6), - Text(formatYmd(value), style: _uiStyle(14.5, FontWeight.w700, AppColors.ink)), + Flexible( + child: Text( + formatYmd(value), + maxLines: 1, + overflow: TextOverflow.fade, + softWrap: false, + style: _uiStyle(14.5, FontWeight.w700, AppColors.ink), + ), + ), ], ), ], 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 4b8be06..1a4e0f4 100644 --- a/flutter/lib/features/schedule/presentation/widgets/event_editor_parts.dart +++ b/flutter/lib/features/schedule/presentation/widgets/event_editor_parts.dart @@ -294,7 +294,8 @@ class RepeatOptions extends StatelessWidget { on: on, onTap: () { onCustom(false); - onSpec(s); + // 매일·평일·주말은 방식만 바꾸고, 직접 설정한 단위·횟수는 남겨 둔다 (시안 setRepeatMode) + onSpec(s.mode == RepeatPattern.every ? s : spec.copyWith(mode: s.mode)); }, ); } @@ -592,7 +593,9 @@ class _CustomSwatch extends StatelessWidget { final Color? color; final VoidCallback onTap; + // CSS conic-gradient 는 12시에서 시작한다 (Flutter 기본은 3시) static const _rainbow = SweepGradient( + transform: GradientRotation(-pi / 2), colors: [ Color(0xFFFF6B6B), Color(0xFFFFC94D), Color(0xFF3FB58C), Color(0xFF4DA6E0), Color(0xFFB07CF0), Color(0xFFFF6B6B), diff --git a/flutter/lib/features/schedule/presentation/widgets/event_tile.dart b/flutter/lib/features/schedule/presentation/widgets/event_tile.dart index 2c4e882..74fb9dc 100644 --- a/flutter/lib/features/schedule/presentation/widgets/event_tile.dart +++ b/flutter/lib/features/schedule/presentation/widgets/event_tile.dart @@ -59,8 +59,6 @@ class EventMeta extends ConsumerWidget { Expanded( child: Text( place == null || place.isEmpty ? label : '$label · $place', - maxLines: 1, - overflow: TextOverflow.ellipsis, style: Theme.of(context).textTheme.bodySmall!.copyWith( color: AppColors.muted, height: AppTypography.normalHeight, @@ -175,8 +173,6 @@ class EventTile extends StatelessWidget { children: [ Text( d.title, - maxLines: 1, - overflow: TextOverflow.ellipsis, style: Theme.of(context).textTheme.titleSmall!.copyWith( fontWeight: FontWeight.w600, height: AppTypography.normalHeight, diff --git a/flutter/lib/features/schedule/presentation/widgets/schedule_calendar.dart b/flutter/lib/features/schedule/presentation/widgets/schedule_calendar.dart index 18ff982..a9438f1 100644 --- a/flutter/lib/features/schedule/presentation/widgets/schedule_calendar.dart +++ b/flutter/lib/features/schedule/presentation/widgets/schedule_calendar.dart @@ -44,6 +44,7 @@ class ScheduleCalendar extends StatefulWidget { required this.selected, required this.today, required this.onSelect, + this.onYearViewChanged, }); final ScheduleBook book; @@ -51,6 +52,9 @@ class ScheduleCalendar extends StatefulWidget { final DateTime today; final ValueChanged onSelect; + /// 12개월 보기로 바뀔 때 — 일정 목록은 그동안 숨긴다 + final ValueChanged? onYearViewChanged; + @override State createState() => _ScheduleCalendarState(); } @@ -90,11 +94,14 @@ class _ScheduleCalendarState extends State { } } + void _setYearView(bool value) { + setState(() => _yearView = value); + widget.onYearViewChanged?.call(value); + } + void _pickMonth(int month) { - setState(() { - _month = month; - _yearView = false; - }); + setState(() => _month = month); + _setYearView(false); widget.onSelect(DateTime(_year, month)); } @@ -108,7 +115,7 @@ class _ScheduleCalendarState extends State { _Head( title: _yearView ? '$_year년' : '$_year년 $_month월', yearView: _yearView, - onTitle: () => setState(() => _yearView = !_yearView), + onTitle: () => _setYearView(!_yearView), onPrev: () => _step(-1), onNext: () => _step(1), ), @@ -433,8 +440,9 @@ class _DayCell extends StatelessWidget { ), ), const SizedBox(height: 3), - SizedBox( - width: _kDotsWidth, + // 시안 .rup-cal-dots { max-width: 18 } — 점 묶음은 내용 폭만큼이라 칸 가운데에 놓인다 + ConstrainedBox( + constraints: const BoxConstraints(maxWidth: _kDotsWidth), child: Wrap( spacing: 2.5, runSpacing: 2.5, diff --git a/flutter/lib/features/schedule/presentation/widgets/schedule_view.dart b/flutter/lib/features/schedule/presentation/widgets/schedule_view.dart index 883a3ad..dc1c0e9 100644 --- a/flutter/lib/features/schedule/presentation/widgets/schedule_view.dart +++ b/flutter/lib/features/schedule/presentation/widgets/schedule_view.dart @@ -17,12 +17,19 @@ import 'package:tma_app/shared/widgets/rup_card.dart'; import 'package:tma_app/shared/widgets/rup_toast.dart'; import 'package:tma_app/shared/widgets/section_title.dart'; -/// 일정 관리 — 함께 관리 · 달력 · 고른 날의 일정 -class ScheduleView extends ConsumerWidget { +/// 일정 관리 — 함께 관리 · 달력 · 고른 날의 일정 (12개월 보기에서는 일정 목록을 숨긴다) +class ScheduleView extends ConsumerStatefulWidget { const ScheduleView({super.key}); @override - Widget build(BuildContext context, WidgetRef ref) { + ConsumerState createState() => _ScheduleViewState(); +} + +class _ScheduleViewState extends ConsumerState { + bool _yearView = false; + + @override + Widget build(BuildContext context) { final book = ref.watch(scheduleBookProvider); final selected = ref.watch(scheduleDateProvider); final today = dateOnly(DateTime.now()); @@ -32,7 +39,6 @@ class ScheduleView extends ConsumerWidget { return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - const SizedBox(height: AppSpacing.xs), const _ShareCard(), const SizedBox(height: AppSpacing.md), ScheduleCalendar( @@ -40,55 +46,58 @@ class ScheduleView extends ConsumerWidget { selected: selected, today: today, onSelect: ref.read(scheduleDateProvider.notifier).select, + onYearViewChanged: (v) => setState(() => _yearView = v), ), - const SizedBox(height: 26), - Padding( - padding: const EdgeInsets.symmetric(horizontal: AppSpacing.xs), - child: Row( - children: [ - Expanded( - child: Text.rich( - TextSpan(children: [ - TextSpan(text: selected == today ? '오늘 일정' : '${formatMonthDay(selected)} 일정'), - const WidgetSpan(child: SizedBox(width: 9)), - TextSpan( - text: '${weekdayKo(selected)}요일 · ${events.length}건', - style: textTheme.bodySmall!.copyWith( - fontSize: 13, - fontWeight: FontWeight.w700, - color: AppColors.muted, - height: AppTypography.normalHeight, + if (!_yearView) ...[ + const SizedBox(height: 26), + Padding( + padding: const EdgeInsets.symmetric(horizontal: AppSpacing.xs), + child: Row( + children: [ + Expanded( + child: Text.rich( + TextSpan(children: [ + TextSpan(text: selected == today ? '오늘 일정' : '${formatMonthDay(selected)} 일정'), + const WidgetSpan(child: SizedBox(width: 9)), + TextSpan( + text: '${weekdayKo(selected)}요일 · ${events.length}건', + style: textTheme.bodySmall!.copyWith( + fontSize: 13, + fontWeight: FontWeight.w700, + color: AppColors.muted, + height: AppTypography.normalHeight, + ), ), + ]), + maxLines: 1, + style: textTheme.titleMedium!.copyWith( + fontWeight: FontWeight.w600, + height: AppTypography.displayNormalHeight, ), - ]), - maxLines: 1, - style: textTheme.titleMedium!.copyWith( - fontWeight: FontWeight.w600, - height: AppTypography.displayNormalHeight, ), ), - ), - RupSoftPillButton( - label: '일정 추가', - uiFont: true, - onPressed: () => unawaited(context.push(AppRoutes.eventCreate(selected))), + RupSoftPillButton( + label: '일정 추가', + uiFont: true, + onPressed: () => unawaited(context.push(AppRoutes.eventCreate(selected))), + ), + ], + ), + ), + const SizedBox(height: AppSpacing.md), + if (events.isEmpty) + const RupCard(child: EmptyMessage('이 날은 일정이 없어요.')) + else + for (final (i, e) in events.indexed) ...[ + if (i > 0) const SizedBox(height: 9), + EventTile( + event: e, + done: book.isDone(e.id), + onTap: () => unawaited(context.push(AppRoutes.eventEdit(e.id))), + onToggleDone: () => unawaited(ref.read(scheduleProvider.notifier).toggleDone(e.id)), ), ], - ), - ), - const SizedBox(height: AppSpacing.md), - if (events.isEmpty) - const RupCard(child: EmptyMessage('이 날은 일정이 없어요.', padding: 40)) - else - for (final (i, e) in events.indexed) ...[ - if (i > 0) const SizedBox(height: 9), - EventTile( - event: e, - done: book.isDone(e.id), - onTap: () => unawaited(context.push(AppRoutes.eventEdit(e.id))), - onToggleDone: () => unawaited(ref.read(scheduleProvider.notifier).toggleDone(e.id)), - ), - ], + ], ], ); } diff --git a/flutter/test/home_register_flow_test.dart b/flutter/test/home_register_flow_test.dart index a2fb75b..ee2e8e5 100644 --- a/flutter/test/home_register_flow_test.dart +++ b/flutter/test/home_register_flow_test.dart @@ -66,5 +66,36 @@ void main() { expect(find.text('명함 만들기'), findsNothing); expect(find.bySemanticsLabel('콩이 명함 공유'), findsOneWidget); }); -} + testWidgets('우리 아이를 꾹 눌러 끌면 순서가 바뀌고 저장된다', (tester) async { + final pets = [samplePet(id: 'pet-1', name: '콩이'), samplePet(id: 'pet-2', name: '보리')]; + final prefs = await launchSignedIn(tester, extra: {'pets.v1': jsonEncode([for (final p in pets) p.toJson()])}); + + final from = tester.getCenter(find.text('콩이')); + final to = tester.getCenter(find.text('보리')); + // 320ms 이상 누르면 들어 올리고, 끌어서 보리 자리에 놓는다 + final gesture = await tester.startGesture(from); + await tester.pump(const Duration(milliseconds: 400)); + await gesture.moveTo(Offset.lerp(from, to, .5)!); + await tester.pump(); + await gesture.moveTo(to); + await tester.pump(); + await gesture.up(); + await settle(tester); + + expect(tester.getCenter(find.text('보리')).dx, lessThan(tester.getCenter(find.text('콩이')).dx)); + final saved = (jsonDecode(prefs.getString('pets.v1')!) as List).cast>(); + expect(saved.map((p) => p['name']), ['보리', '콩이']); + }); + + testWidgets('짧게 누르면 순서는 그대로 두고 그 아이를 고른다', (tester) async { + final pets = [samplePet(id: 'pet-1', name: '콩이'), samplePet(id: 'pet-2', name: '보리')]; + final prefs = await launchSignedIn(tester, extra: {'pets.v1': jsonEncode([for (final p in pets) p.toJson()])}); + + await tester.tap(find.text('보리')); + await settle(tester); + expect(find.text('보리의 펫 명함이 아직 없어요', findRichText: true), findsOneWidget); + final saved = (jsonDecode(prefs.getString('pets.v1')!) as List).cast>(); + expect(saved.map((p) => p['name']), ['콩이', '보리']); + }); +} diff --git a/flutter/test/pet_domain_test.dart b/flutter/test/pet_domain_test.dart index 9c88b5b..6b10ebd 100644 --- a/flutter/test/pet_domain_test.dart +++ b/flutter/test/pet_domain_test.dart @@ -7,6 +7,8 @@ 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/domain/pet_species.dart'; +import 'support/app_harness.dart'; + PetInput _input({String name = '콩이', double weight = 3.2}) => PetInput( name: name, species: '강아지', @@ -138,4 +140,20 @@ void main() { expect(await repo.fetchPets(), isEmpty); }); }); + + group('reorderPets — 우리 아이 순서 바꾸기', () { + final pets = [samplePet(id: 'a', name: '콩이'), samplePet(id: 'b', name: '보리'), samplePet(id: 'c', name: '초코')]; + + test('준 순서대로 줄 세운다', () { + expect(reorderPets(pets, ['c', 'a', 'b']).map((p) => p.id), ['c', 'a', 'b']); + }); + + test('목록에 없는 아이(추억 보관함 등)는 원래 순서대로 뒤에 둔다', () { + expect(reorderPets(pets, ['b', 'a']).map((p) => p.id), ['b', 'a', 'c']); + }); + + test('없는 id 는 건너뛴다', () { + expect(reorderPets(pets, ['x', 'b']).map((p) => p.id), ['b', 'a', 'c']); + }); + }); } diff --git a/flutter/test/schedule_flow_test.dart b/flutter/test/schedule_flow_test.dart index 174ac2e..1e5f55c 100644 --- a/flutter/test/schedule_flow_test.dart +++ b/flutter/test/schedule_flow_test.dart @@ -138,4 +138,77 @@ void main() { expect(find.text('예방접종'), findsOneWidget); expect(find.text('예약된 일정이 없어요'), findsNothing); }); + + testWidgets('하루 종일이 켜진 채 기간을 하루 2회로 정하면 회차 시각(08:00·20:00)으로 저장된다', (tester) async { + final prefs = await launchSignedIn(tester, extra: _withPet()); + await _openRecords(tester); + await tester.tap(find.text('일정 추가')); + await settle(tester); + + await tester.enterText(find.byType(TextField).first, '영양제'); + await _tapVisible(tester, find.bySemanticsLabel('기간 설정')); + await _tapVisible(tester, find.text('2회')); + await _tapVisible(tester, find.text('일정 저장')); + + final saved = jsonDecode(prefs.getString('schedule.events.v1')!) as List; + final starts = {for (final e in saved.cast>()) e['start']}; + expect(starts, {'08:00', '20:00'}); + }); + + testWidgets('예약 분류를 골랐다가 같은 일반 일정 칩을 다시 누르면 분류 선택이 풀린다', (tester) async { + await launchSignedIn(tester, extra: _withPet()); + await _openRecords(tester); + await tester.tap(find.text('일정 추가')); + await settle(tester); + + await _tapVisible(tester, find.text('목욕')); + await _tapVisible(tester, find.bySemanticsLabel('병원·검진')); + expect(tester.getSemantics(find.bySemanticsLabel('병원·검진')), isSemantics(isSelected: true)); + + // 이름 칸에도 '목욕' 이 들어 있으므로 위쪽의 칩을 누른다 + await _tapVisible(tester, find.text('목욕').first); + expect(tester.getSemantics(find.bySemanticsLabel('병원·검진')), isSemantics(isSelected: false)); + }); + + testWidgets('주기 직접 설정은 2주마다로 시작하고, 매일·평일을 골랐다 돌아와도 단위·횟수가 남는다', (tester) async { + await launchSignedIn(tester, extra: _withPet()); + await _openRecords(tester); + await tester.tap(find.text('일정 추가')); + await settle(tester); + + await _tapVisible(tester, find.bySemanticsLabel('반복 일정')); + await _tapVisible(tester, find.text('주기 직접 설정')); + expect(find.text('2주 마다 반복'), findsWidgets); + + await _tapVisible(tester, find.text('평일만')); + expect(find.text('평일마다 반복'), findsOneWidget); + await _tapVisible(tester, find.text('주기 직접 설정')); + expect(find.text('2주 마다 반복'), findsWidgets); + }); + + testWidgets('시간 휠의 가운데 숫자를 누르면 직접 입력할 수 있다', (tester) async { + await launchSignedIn(tester, extra: _withPet()); + await _openRecords(tester); + await tester.tap(find.text('일정 추가')); + await settle(tester); + + await _tapVisible(tester, find.text('시작')); + expect(find.text('오후 2:00 선택'), findsOneWidget); + + // 가운데(선택된) 시 — 02 + await tester.tap(find.text('02').first); + await tester.pump(); + await tester.enterText(find.byType(TextField).last, '7'); + await tester.testTextInput.receiveAction(TextInputAction.done); + await settle(tester); + expect(find.text('오후 7:00 선택'), findsOneWidget); + + // 범위를 넘으면 가장 가까운 값으로 (분 99 → 59) + await tester.tap(find.text('00').first); + await tester.pump(); + await tester.enterText(find.byType(TextField).last, '99'); + await tester.testTextInput.receiveAction(TextInputAction.done); + await settle(tester); + expect(find.text('오후 7:59 선택'), findsOneWidget); + }); }