diff --git a/flutter/lib/core/constants/app_colors.dart b/flutter/lib/core/constants/app_colors.dart index 77fc7d5..0d1af9f 100644 --- a/flutter/lib/core/constants/app_colors.dart +++ b/flutter/lib/core/constants/app_colors.dart @@ -32,6 +32,7 @@ abstract final class AppColors { static const Color catGrooming = Color(0xFFB07CF0); static const Color catWalk = Color(0xFF3FB58C); static const Color catFood = Color(0xFFFFAA4D); + static const Color catMed = Color(0xFFF2974E); static const Color catEtc = Color(0xFF9AA0A6); // 하단 탭바 반투명 배경 — rgba(255,253,250,.86) diff --git a/flutter/lib/core/router/app_router.dart b/flutter/lib/core/router/app_router.dart index ba23ca3..896770f 100644 --- a/flutter/lib/core/router/app_router.dart +++ b/flutter/lib/core/router/app_router.dart @@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:tma_app/core/constants/app_sizes.dart'; import 'package:tma_app/core/router/app_routes.dart'; +import 'package:tma_app/core/utils/date_format.dart'; import 'package:tma_app/features/auth/presentation/pages/login_page.dart'; import 'package:tma_app/features/entry/presentation/providers/entry_provider.dart'; import 'package:tma_app/features/home/presentation/pages/home_page.dart'; @@ -19,8 +20,9 @@ import 'package:tma_app/features/pet_card/presentation/pages/pet_card_detail_pag import 'package:tma_app/features/pet_card/presentation/pages/pet_card_editor_page.dart'; import 'package:tma_app/features/pet_card/presentation/pages/pet_card_manage_page.dart'; import 'package:tma_app/features/pet_card/presentation/pages/pet_cards_page.dart'; +import 'package:tma_app/features/records/presentation/pages/records_page.dart'; +import 'package:tma_app/features/schedule/presentation/pages/event_editor_page.dart'; import 'package:tma_app/features/shell/presentation/pages/main_shell.dart'; -import 'package:tma_app/features/shell/presentation/pages/tab_placeholder_page.dart'; import 'package:tma_app/features/splash/presentation/pages/splash_page.dart'; /// 진입 화면 전환 — 시안은 화면을 겹쳐 두고 페이드로 넘긴다 @@ -80,6 +82,18 @@ final appRouterProvider = Provider((ref) { builder: (context, state) => PetRegisterPage(petId: state.pathParameters['petId']), ), + GoRoute( + path: AppRoutes.eventCreatePath, + parentNavigatorKey: rootKey, + builder: (context, state) => EventEditorPage.create( + date: parseIsoDateOrNull(state.uri.queryParameters['date']) ?? dateOnly(DateTime.now()), + ), + ), + GoRoute( + path: AppRoutes.eventEditPath, + parentNavigatorKey: rootKey, + builder: (context, state) => EventEditorPage.edit(eventId: state.uri.queryParameters['id'] ?? ''), + ), // 펫 명함 — 경로가 겹치므로 고정 경로(manage·new)를 :cardId 보다 먼저 둔다 GoRoute( path: AppRoutes.petCardManage, @@ -120,8 +134,7 @@ final appRouterProvider = Provider((ref) { routes: [ GoRoute( path: AppRoutes.records, - builder: (context, state) => - const TabPlaceholderPage(title: '일정·기록'), + builder: (context, state) => const RecordsPage(), ), ], ), diff --git a/flutter/lib/core/router/app_routes.dart b/flutter/lib/core/router/app_routes.dart index 78c1a39..40e25c7 100644 --- a/flutter/lib/core/router/app_routes.dart +++ b/flutter/lib/core/router/app_routes.dart @@ -1,3 +1,4 @@ +import 'package:tma_app/core/utils/date_format.dart'; import 'package:tma_app/features/entry/domain/entry_state.dart'; import 'package:tma_app/features/mypage/domain/policy_doc.dart'; @@ -25,6 +26,12 @@ abstract final class AppRoutes { static const String petEditPattern = '/pets/:petId/edit'; static String petEdit(String petId) => '/pets/$petId/edit'; + // 일정 추가·수정 — 반복 일정 id 에 '@' 가 있어 쿼리로 넘긴다 + static const String eventCreatePath = '/schedule/new'; + static const String eventEditPath = '/schedule/edit'; + static String eventCreate(DateTime date) => '$eventCreatePath?date=${toIsoDate(date)}'; + static String eventEdit(String eventId) => '$eventEditPath?id=${Uri.encodeQueryComponent(eventId)}'; + static const String petCardManage = '/pet-cards/manage'; static const String petCardCreatePattern = '/pet-cards/new/:petId'; static const String petCardDetailPattern = '/pet-cards/:cardId'; diff --git a/flutter/lib/features/home/presentation/pages/home_page.dart b/flutter/lib/features/home/presentation/pages/home_page.dart index e2b4425..a102e0b 100644 --- a/flutter/lib/features/home/presentation/pages/home_page.dart +++ b/flutter/lib/features/home/presentation/pages/home_page.dart @@ -7,6 +7,7 @@ import 'package:tma_app/core/router/app_routes.dart'; import 'package:tma_app/features/auth/presentation/providers/user_provider.dart'; import 'package:tma_app/features/home/presentation/widgets/home_pets_section.dart'; import 'package:tma_app/features/home/presentation/widgets/home_schedule_section.dart'; +import 'package:tma_app/features/records/presentation/providers/records_tab_provider.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'; @@ -21,7 +22,6 @@ class HomePage extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final nickname = ref.watch(userNicknameProvider); - final today = DateTime.now(); Widget padded(Widget child) => Padding( padding: const EdgeInsets.symmetric(horizontal: AppSpacing.xl), @@ -53,12 +53,7 @@ class HomePage extends ConsumerWidget { ), padded(HomePetsSection(onRegister: () => context.push(AppRoutes.petRegister))), const DividerBand(), - padded( - HomeScheduleSection( - today: today, - onSeeAll: () => context.go(AppRoutes.records), - ), - ), + padded(const HomeScheduleSection()), const DividerBand(), padded( Padding( @@ -66,7 +61,10 @@ class HomePage extends ConsumerWidget { child: SectionTitle( title: '다이어리', trailing: '더보기', - onTrailingTap: () => context.go(AppRoutes.records), + onTrailingTap: () { + ref.read(recordsTabProvider.notifier).select(RecordsTab.diary); + context.go(AppRoutes.records); + }, ), ), ), 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 72c299e..6fe2db3 100644 --- a/flutter/lib/features/home/presentation/widgets/home_schedule_section.dart +++ b/flutter/lib/features/home/presentation/widgets/home_schedule_section.dart @@ -1,21 +1,70 @@ +import 'dart:async'; +import 'dart:math' as math; + 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/core/utils/date_format.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'; -/// 홈 "일정" — 오늘 일정(다음 일정·오늘 할 일) + 예약 일정. -/// 일정 기능을 만들기 전이라 시안의 "일정 없음" 상태를 보여 준다. -class HomeScheduleSection extends StatelessWidget { - const HomeScheduleSection({super.key, required this.today, required this.onSeeAll}); +const double _kNormal = AppTypography.normalHeight; - final DateTime today; - final VoidCallback onSeeAll; +/// 홈에 보여 줄 오늘 일정 수 — 넘치면 "남은 일정 N개 더 보기" +const int _kVisibleTasks = 4; + +/// 밤 11시부터 자정까지는 하루 정리 모드 — 오늘 일정 전체를 완료/놓침으로 보여 준다 +const int _kReviewHour = 23; + +/// 놓친 일정 표시 색 (시안 #C4543F) +const Color _kMissed = Color(0xFFC4543F); + +/// 다음 일정 카드 테두리가 한 바퀴 도는 시간 +const Duration _kRingSpin = Duration(milliseconds: 3200); + +/// 일정·기록 탭의 일정 관리를 연다 — [date] 를 주면 그 날짜를 고른 채로 +void openSchedule(BuildContext context, WidgetRef ref, {DateTime? date}) { + ref.read(recordsTabProvider.notifier).select(RecordsTab.schedule); + if (date != null) ref.read(scheduleDateProvider.notifier).select(date); + context.go(AppRoutes.records); +} + +/// "D-3" · "오늘" · "지남" (시안 dDay) +String dDayLabel(DateTime date, DateTime today) { + final diff = DateTime.utc(date.year, date.month, date.day).difference(DateTime.utc(today.year, today.month, today.day)).inDays; + if (diff == 0) return '오늘'; + if (diff < 0) return '지남'; + return 'D-$diff'; +} + +/// 홈 "일정" — 오늘 일정(다음 일정·오늘 할 일) + 예약 일정 +class HomeScheduleSection extends ConsumerWidget { + const HomeScheduleSection({super.key}); @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { + final book = ref.watch(scheduleBookProvider); + final now = DateTime.now(); + final today = dateOnly(now); + final tasks = book.todayTasks(today); + final pending = tasks.where((t) => !book.isDone(t.id)).toList(); + final doneCount = tasks.length - pending.length; + final review = now.hour == _kReviewHour; + final visible = review ? tasks : pending.take(_kVisibleTasks).toList(); + final hidden = review ? 0 : pending.length - visible.length; + final upcoming = book.upcomingReservations(today); + final next = pending.firstOrNull; + return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ @@ -23,7 +72,7 @@ class HomeScheduleSection extends StatelessWidget { const SizedBox(height: AppSpacing.xs), Padding( padding: const EdgeInsets.symmetric(horizontal: AppSpacing.xs), - child: SectionTitle(title: '일정', trailing: '전체보기', onTrailingTap: onSeeAll), + child: SectionTitle(title: '일정', trailing: '전체보기', onTrailingTap: () => openSchedule(context, ref)), ), const SizedBox(height: 21), Padding( @@ -35,14 +84,33 @@ class HomeScheduleSection extends StatelessWidget { ), ), const SizedBox(height: 10), - const _NextTaskOff(text: '오늘 등록된 일정이 없어요'), + if (next != null) + _NextTaskCard(event: next, onTap: () => openSchedule(context, ref, date: today)) + else + _NextTaskOff(text: tasks.isEmpty ? '오늘 등록된 일정이 없어요' : '오늘의 일정을 전부 끝냈어요! 고생하셨습니다 🧡'), const SizedBox(height: AppSpacing.md), - const RupCard( + if (review && tasks.isNotEmpty) ...[ + _ReviewCard(done: doneCount, missed: tasks.length - doneCount), + const SizedBox(height: 10), + ], + RupCard( child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - _TodayProgress(done: 0, total: 0), - EmptyMessage('오늘은 등록된 일정이 없어요'), + _TodayProgress(done: doneCount, total: tasks.length), + if (tasks.isEmpty) + const EmptyMessage('오늘은 등록된 일정이 없어요') + else if (!review && pending.isEmpty) + const EmptyMessage('남은 오늘 일정이 없어요'), + for (final (i, t) in visible.indexed) + _TaskRow( + event: t, + done: book.isDone(t.id), + missed: review && !book.isDone(t.id), + first: i == 0, + onTap: () => unawaited(ref.read(scheduleProvider.notifier).toggleDone(t.id)), + ), + if (hidden > 0) _MoreRow(count: hidden, onTap: () => openSchedule(context, ref, date: today)), ], ), ), @@ -52,12 +120,146 @@ class HomeScheduleSection extends StatelessWidget { child: SectionTitle(title: '예약 일정', sub: true, trailing: '병원 · 미용 · 검진'), ), const SizedBox(height: 10), - const RupCard(child: EmptyMessage('예약된 일정이 없어요')), + RupCard( + child: upcoming.isEmpty + ? const EmptyMessage('예약된 일정이 없어요') + : Column( + children: [ + for (final (i, e) in upcoming.indexed) + _UpcomingRow( + event: e, + today: today, + first: i == 0, + onTap: () => openSchedule(context, ref, date: e.date), + ), + ], + ), + ), ], ); } } +/// 바로 다음 할 일 — 연한 주황 테두리가 천천히 돈다 (.rup-ring-card) +class _NextTaskCard extends StatelessWidget { + const _NextTaskCard({required this.event, required this.onTap}); + + final ScheduleEvent event; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final d = event.details; + final textTheme = Theme.of(context).textTheme; + return Semantics( + container: true, + button: true, + child: GestureDetector( + onTap: onTap, + child: _SpinningRing( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 9), + child: Row( + children: [ + PetStack(targetIds: d.targetIds, size: 30), + const SizedBox(width: 10), + Expanded( + child: Text( + d.title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: textTheme.bodyMedium!.copyWith(fontSize: 14, fontWeight: FontWeight.w700, height: _kNormal), + ), + ), + const SizedBox(width: 10), + Text( + '오늘 ${d.start?.label ?? kAllDayLabel}', + style: textTheme.bodySmall!.copyWith(color: AppColors.muted, height: _kNormal), + ), + ], + ), + ), + ), + ), + ); + } +} + +class _SpinningRing extends StatefulWidget { + const _SpinningRing({required this.child}); + + final Widget child; + + @override + State<_SpinningRing> createState() => _SpinningRingState(); +} + +class _SpinningRingState extends State<_SpinningRing> with SingleTickerProviderStateMixin { + late final AnimationController _spin = AnimationController(vsync: this, duration: _kRingSpin); + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + // 움직임 줄이기 설정이면 돌리지 않는다 + if (MediaQuery.disableAnimationsOf(context)) { + _spin.stop(); + } else if (!_spin.isAnimating) { + _spin.repeat(); + } + } + + @override + void dispose() { + _spin.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return RupCard( + borderColor: Colors.transparent, + child: CustomPaint( + foregroundPainter: _RingPainter(_spin), + child: widget.child, + ), + ); + } +} + +class _RingPainter extends CustomPainter { + _RingPainter(this.spin) : super(repaint: spin); + + final Animation spin; + + static final Color _soft = Color.lerp(AppColors.primary, Colors.white, .88)!; + static final Color _strong = Color.lerp(AppColors.primary, Colors.white, .62)!; + static final Color _clear = _soft.withValues(alpha: 0); + + @override + void paint(Canvas canvas, Size size) { + final rrect = RRect.fromRectAndRadius( + (Offset.zero & size).deflate(.75), + const Radius.circular(AppRadius.card), + ); + // 늘 보이는 옅은 테두리 + canvas.drawRRect(rrect, Paint()..style = PaintingStyle.stroke..strokeWidth = 1..color = _soft); + // 도는 밝은 띠 — 0°~225° 구간에만 색이 있다 + final ring = Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = 1.5 + ..shader = SweepGradient( + // 투명도 0 인 같은 색으로 흐려야 한다 — Colors.transparent(검정)로 흐리면 이음매가 회색으로 비친다 + colors: [_clear, _soft, _strong, _soft, _clear, _clear], + stops: const [0, 55 / 360, 110 / 360, 165 / 360, 225 / 360, 1], + transform: GradientRotation(spin.value * 2 * math.pi - math.pi / 2), + ).createShader(Offset.zero & size); + canvas.drawRRect(rrect, ring); + } + + @override + bool shouldRepaint(_RingPainter oldDelegate) => false; +} + /// 다음 일정 카드의 비활성 상태 (.rup-nextcard--off) class _NextTaskOff extends StatelessWidget { const _NextTaskOff({required this.text}); @@ -93,6 +295,52 @@ class _NextTaskOff extends StatelessWidget { } } +/// 밤 11시 — 오늘 하루 정리 +class _ReviewCard extends StatelessWidget { + const _ReviewCard({required this.done, required this.missed}); + + final int done; + final int missed; + + @override + Widget build(BuildContext context) { + final textTheme = Theme.of(context).textTheme; + return RupCard( + color: AppColors.primary50, + borderColor: AppColors.primary.withValues(alpha: .22), + padding: const EdgeInsets.symmetric(horizontal: 15, vertical: 13), + child: Row( + children: [ + Container( + width: 32, + height: 32, + decoration: BoxDecoration(color: AppColors.surface, borderRadius: BorderRadius.circular(10)), + alignment: Alignment.center, + child: const RupIcon(RupIconName.moon, size: 17, stroke: 2, color: AppColors.primary600), + ), + const SizedBox(width: 11), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '오늘 하루 정리', + style: textTheme.bodyMedium!.copyWith(fontWeight: FontWeight.w800, height: _kNormal), + ), + const SizedBox(height: 1.5), + Text( + missed == 0 ? '오늘 일정을 모두 끝냈어요 🧡' : '완료 $done개 · 놓친 일정 $missed개', + style: textTheme.bodySmall!.copyWith(color: AppColors.ink2, height: _kNormal), + ), + ], + ), + ), + ], + ), + ); + } +} + /// 오늘 할 일 진행도 — "오늘 할 일 [■■□] n/m 완료" class _TodayProgress extends StatelessWidget { const _TodayProgress({required this.done, required this.total}); @@ -121,11 +369,15 @@ class _TodayProgress extends StatelessWidget { Expanded( child: ClipRRect( borderRadius: BorderRadius.circular(6), - child: LinearProgressIndicator( - value: total == 0 ? 0 : done / total, - minHeight: 6, - backgroundColor: AppColors.line2, - color: AppColors.primary, + 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, + ), ), ), ), @@ -142,3 +394,216 @@ class _TodayProgress extends StatelessWidget { ); } } + +/// 오늘 할 일 한 줄 — 누르면 완료 체크 (.rup-task) +class _TaskRow extends ConsumerWidget { + const _TaskRow({ + required this.event, + required this.done, + required this.missed, + required this.first, + required this.onTap, + }); + + final ScheduleEvent event; + final bool done; + final bool missed; + final bool first; + final VoidCallback onTap; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final d = event.details; + final textTheme = Theme.of(context).textTheme; + final names = ref.watch(targetNamesProvider); + final time = [ + targetLabel(d.targetIds, names), + '${d.start?.label ?? kAllDayLabel}${d.end == null ? '' : ' ~ ${d.end!.label}'}', + ?d.place, + ].where((s) => s.isNotEmpty).join(' · '); + + return Semantics( + container: true, + button: true, + checked: done, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: onTap, + child: 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: [ + RupCheckBox(done: done), + const SizedBox(width: AppSpacing.md), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + d.title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: textTheme.titleSmall!.copyWith( + fontWeight: FontWeight.w600, + height: _kNormal, + color: done ? AppColors.muted : AppColors.ink, + decoration: done ? TextDecoration.lineThrough : null, + decorationColor: AppColors.muted, + ), + ), + Text( + time, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: textTheme.bodySmall!.copyWith(color: AppColors.muted, height: _kNormal), + ), + ], + ), + ), + if (missed) ...[ + const SizedBox(width: AppSpacing.md), + Container( + padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 4), + decoration: BoxDecoration( + color: _kMissed.withValues(alpha: .12), + borderRadius: BorderRadius.circular(AppRadius.pill), + ), + child: Text( + '놓침', + style: textTheme.bodySmall!.copyWith( + fontSize: 11.5, + fontWeight: FontWeight.w800, + color: _kMissed, + height: _kNormal, + ), + ), + ), + ], + ], + ), + ), + ), + ); + } +} + +class _MoreRow extends StatelessWidget { + const _MoreRow({required this.count, required this.onTap}); + + final int count; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return Semantics( + container: true, + button: true, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: onTap, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: AppSpacing.lg, vertical: AppSpacing.md), + decoration: const BoxDecoration(border: Border(top: BorderSide(color: AppColors.line2))), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + '남은 일정 $count개 더 보기', + style: Theme.of(context).textTheme.bodyMedium!.copyWith( + fontSize: 13, + fontWeight: FontWeight.w700, + color: AppColors.primary600, + height: _kNormal, + ), + ), + const SizedBox(width: 3), + const RupIcon(RupIconName.chevR, size: 13, color: AppColors.primary600), + ], + ), + ), + ), + ); + } +} + +/// 예약 일정 한 줄 — 색 띠 + 분류 아이콘 + D-day (.rup-uprow) +class _UpcomingRow extends StatelessWidget { + const _UpcomingRow({required this.event, required this.today, required this.first, required this.onTap}); + + final ScheduleEvent event; + final DateTime today; + final bool first; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final d = event.details; + final textTheme = Theme.of(context).textTheme; + return Semantics( + container: true, + button: true, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: onTap, + child: DecoratedBox( + decoration: BoxDecoration( + border: first ? null : const Border(top: BorderSide(color: AppColors.line2)), + ), + child: IntrinsicHeight( + child: Row( + children: [ + // 자식 없는 Container 는 줄 높이만큼 늘어난다 (ColoredBox 는 높이 0 이 된다) + Container(width: 4, color: d.color), + Expanded( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: AppSpacing.lg, vertical: 13), + child: Row( + children: [ + CategoryBadge(details: d, size: 36, iconSize: 18, radius: 11), + const SizedBox(width: AppSpacing.md), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + 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), + ], + ), + ), + const SizedBox(width: AppSpacing.md), + Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: d.color.withValues(alpha: .12), + borderRadius: BorderRadius.circular(9), + ), + child: Text( + dDayLabel(event.date, today), + style: textTheme.bodySmall!.copyWith( + fontSize: 12.5, + fontWeight: FontWeight.w800, + color: d.color, + height: _kNormal, + ), + ), + ), + ], + ), + ), + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/flutter/lib/features/mypage/presentation/providers/account_actions.dart b/flutter/lib/features/mypage/presentation/providers/account_actions.dart index 37c1083..b89bd50 100644 --- a/flutter/lib/features/mypage/presentation/providers/account_actions.dart +++ b/flutter/lib/features/mypage/presentation/providers/account_actions.dart @@ -6,6 +6,8 @@ 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'; +import 'package:tma_app/features/records/presentation/providers/records_tab_provider.dart'; +import 'package:tma_app/features/schedule/presentation/providers/schedule_providers.dart'; /// 계정 동작 — 로그아웃 / 회원탈퇴 class AccountActions { @@ -29,6 +31,10 @@ class AccountActions { ..invalidate(petCardsProvider) ..invalidate(userBackgroundsProvider) ..invalidate(profileProvider) + ..invalidate(scheduleProvider) + ..invalidate(scheduleDateProvider) + ..invalidate(carePresetsProvider) + ..invalidate(recordsTabProvider) ..invalidate(appSettingsProvider) ..invalidate(entryProvider); try { diff --git a/flutter/lib/features/pet/presentation/pages/pet_register_page.dart b/flutter/lib/features/pet/presentation/pages/pet_register_page.dart index 1716321..5bc8c9e 100644 --- a/flutter/lib/features/pet/presentation/pages/pet_register_page.dart +++ b/flutter/lib/features/pet/presentation/pages/pet_register_page.dart @@ -19,6 +19,7 @@ import 'package:tma_app/features/pet/presentation/widgets/pet_form_parts.dart'; import 'package:tma_app/features/pet/presentation/widgets/pet_photo_slot.dart'; import 'package:tma_app/features/pet/presentation/widgets/pet_pick_sheets.dart'; import 'package:tma_app/features/pet_card/presentation/providers/pet_card_providers.dart'; +import 'package:tma_app/features/schedule/presentation/providers/schedule_providers.dart'; import 'package:tma_app/shared/widgets/date_picker_sheet.dart'; import 'package:tma_app/shared/widgets/icons/rup_icon.dart'; import 'package:tma_app/shared/widgets/rup_button.dart'; @@ -263,8 +264,9 @@ class _PetRegisterFormState extends ConsumerState<_PetRegisterForm> { ); if (!ok || !mounted) return; await ref.read(petListProvider.notifier).deletePet(editing.id); - // 아이의 펫 명함도 함께 지운다 (일정은 일정 기능을 만들 때 함께 정리한다) + // 아이의 펫 명함도 함께 지우고, 일정에서는 그 아이만 뗀다 await ref.read(petCardsProvider.notifier).deleteForPet(editing.id); + await ref.read(scheduleProvider.notifier).dropTarget(editing.id); ref.read(toastProvider.notifier).show('반려동물을 삭제했어요'); if (mounted) context.go(AppRoutes.myPets); } diff --git a/flutter/lib/features/records/presentation/pages/records_page.dart b/flutter/lib/features/records/presentation/pages/records_page.dart new file mode 100644 index 0000000..bf49b3c --- /dev/null +++ b/flutter/lib/features/records/presentation/pages/records_page.dart @@ -0,0 +1,56 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:tma_app/core/constants/app_sizes.dart'; +import 'package:tma_app/features/records/presentation/providers/records_tab_provider.dart'; +import 'package:tma_app/features/schedule/presentation/widgets/schedule_view.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'; +import 'package:tma_app/shared/widgets/seg_toggle.dart'; + +/// 탭바 뒤로 콘텐츠가 가려지지 않게 두는 여유 +const double _kBottomGap = 30; + +/// 일정·기록 탭 — 일정 관리 / 다이어리 +class RecordsPage extends ConsumerWidget { + const RecordsPage({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final tab = ref.watch(recordsTabProvider); + return SafeArea( + bottom: false, + child: ListView( + padding: EdgeInsets.fromLTRB( + AppSpacing.xl, + AppSpacing.xs, + AppSpacing.xl, + MediaQuery.paddingOf(context).bottom + _kBottomGap, + ), + children: [ + SegToggle( + large: true, + first: (RecordsTab.schedule, RecordsTab.schedule.label), + second: (RecordsTab.diary, RecordsTab.diary.label), + value: tab, + onChanged: ref.read(recordsTabProvider.notifier).select, + iconOf: (t, on) => t == RecordsTab.schedule + ? RupIcon(RupIconName.calendar, size: 18, stroke: on ? 2.4 : 1.9) + : const RupGlyphIcon(RupGlyph.diary, size: 18), + ), + const SizedBox(height: 18), + AnimatedSwitcher( + duration: const Duration(milliseconds: 300), + child: tab == RecordsTab.schedule + ? const ScheduleView(key: ValueKey(RecordsTab.schedule)) + // 다이어리는 다음 단계에서 만든다 + : const RupCard( + key: ValueKey(RecordsTab.diary), + child: EmptyMessage('다이어리는 준비 중이에요', padding: 40), + ), + ), + ], + ), + ); + } +} diff --git a/flutter/lib/features/records/presentation/providers/records_tab_provider.dart b/flutter/lib/features/records/presentation/providers/records_tab_provider.dart new file mode 100644 index 0000000..535be7d --- /dev/null +++ b/flutter/lib/features/records/presentation/providers/records_tab_provider.dart @@ -0,0 +1,21 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +/// 일정·기록 탭 안의 두 화면 +enum RecordsTab { + schedule('일정 관리'), + diary('다이어리'); + + const RecordsTab(this.label); + + final String label; +} + +/// 일정·기록 탭에서 보고 있는 화면 — 홈의 "전체보기"·"더보기" 가 원하는 쪽을 열어 둔다 +class RecordsTabNotifier extends Notifier { + @override + RecordsTab build() => RecordsTab.schedule; + + void select(RecordsTab tab) => state = tab; +} + +final recordsTabProvider = NotifierProvider(RecordsTabNotifier.new); diff --git a/flutter/lib/features/schedule/data/schedule_repository.dart b/flutter/lib/features/schedule/data/schedule_repository.dart new file mode 100644 index 0000000..55ad968 --- /dev/null +++ b/flutter/lib/features/schedule/data/schedule_repository.dart @@ -0,0 +1,77 @@ +import 'dart:convert'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:tma_app/core/storage/preferences_provider.dart'; +import 'package:tma_app/features/schedule/domain/care_preset.dart'; +import 'package:tma_app/features/schedule/domain/repeat_rule.dart'; +import 'package:tma_app/features/schedule/domain/schedule_book.dart'; +import 'package:tma_app/features/schedule/domain/schedule_event.dart'; + +/// 일정 저장소 계약 — 백엔드 API 가 생기면 Dio 구현으로 바꾼다. +abstract interface class ScheduleRepository { + Future fetch(); + + Future saveEvents(List events); + + Future saveRules(List rules); + + Future saveDone(Set doneIds); +} + +const String _kEventsKey = 'schedule.events.v1'; +const String _kRulesKey = 'schedule.rules.v1'; +const String _kDoneKey = 'schedule.done.v1'; +const String _kCaresKey = 'schedule.cares.v1'; + +List> _decodeList(String? raw) => + raw == null ? const [] : (jsonDecode(raw) as List).whereType>().toList(); + +/// 임시 구현 — 기기(SharedPreferences)에 JSON 으로 저장한다. +class LocalScheduleRepository implements ScheduleRepository { + LocalScheduleRepository(this._prefs); + + final SharedPreferences _prefs; + + @override + Future fetch() async => ScheduleBook( + events: _decodeList(_prefs.getString(_kEventsKey)).map(ScheduleEvent.fromJson).toList(), + rules: _decodeList(_prefs.getString(_kRulesKey)).map(RepeatRule.fromJson).toList(), + doneIds: (_prefs.getStringList(_kDoneKey) ?? const []).toSet(), + ); + + @override + Future saveEvents(List events) => + _prefs.setString(_kEventsKey, jsonEncode([for (final e in events) e.toJson()])); + + @override + Future saveRules(List rules) => + _prefs.setString(_kRulesKey, jsonEncode([for (final r in rules) r.toJson()])); + + @override + Future saveDone(Set doneIds) => _prefs.setStringList(_kDoneKey, doneIds.toList()); +} + +final scheduleRepositoryProvider = Provider((ref) { + return LocalScheduleRepository(ref.watch(sharedPreferencesProvider)); +}); + +/// 자주 쓰는 일반 일정 목록 — 지우거나 더한 목록이 다음 일정 추가에도 남는다 +class CarePresetRepository { + CarePresetRepository(this._prefs); + + final SharedPreferences _prefs; + + List load() { + final raw = _prefs.getString(_kCaresKey); + if (raw == null) return kDefaultCarePresets; + return _decodeList(raw).map(CarePreset.fromJson).toList(); + } + + Future save(List presets) => + _prefs.setString(_kCaresKey, jsonEncode([for (final p in presets) p.toJson()])); +} + +final carePresetRepositoryProvider = Provider((ref) { + return CarePresetRepository(ref.watch(sharedPreferencesProvider)); +}); diff --git a/flutter/lib/features/schedule/domain/care_preset.dart b/flutter/lib/features/schedule/domain/care_preset.dart new file mode 100644 index 0000000..7487aa0 --- /dev/null +++ b/flutter/lib/features/schedule/domain/care_preset.dart @@ -0,0 +1,31 @@ +import 'package:tma_app/features/schedule/domain/event_category.dart'; + +/// 자주 쓰는 일반 일정(데일리 케어) — 누르면 일정 이름과 분류가 채워진다 +class CarePreset { + const CarePreset(this.title, this.category); + + final String title; + final EventCategory category; + + Map toJson() => {'title': title, 'category': category.name}; + + factory CarePreset.fromJson(Map json) => + CarePreset(json['title']! as String, EventCategory.byName(json['category'] as String?)); +} + +/// 기본 목록 (시안 CARE_PRESETS) — 사용자가 지우거나 더한 목록은 기기에 남는다 +const List kDefaultCarePresets = [ + CarePreset('발톱 깎기', EventCategory.grooming), + CarePreset('빗질하기', EventCategory.grooming), + CarePreset('목욕', EventCategory.grooming), + CarePreset('양치질', EventCategory.grooming), + CarePreset('귀 청소', EventCategory.grooming), + CarePreset('약 먹이기', EventCategory.med), + CarePreset('영양제', EventCategory.med), + CarePreset('체중 측정', EventCategory.etc), + CarePreset('배변패드 교체', EventCategory.etc), + CarePreset('사료 주문', EventCategory.etc), +]; + +/// 직접 추가하는 일반 일정 이름 최대 길이 +const int kCarePresetMaxLength = 12; diff --git a/flutter/lib/features/schedule/domain/event_category.dart b/flutter/lib/features/schedule/domain/event_category.dart new file mode 100644 index 0000000..019ffe9 --- /dev/null +++ b/flutter/lib/features/schedule/domain/event_category.dart @@ -0,0 +1,42 @@ +import 'dart:ui' show Color; + +import 'package:tma_app/core/constants/app_colors.dart'; +import 'package:tma_app/shared/widgets/icons/rup_icon.dart'; + +/// 일정 분류 — 달력 점·목록 띠의 기본 색과 아이콘 (시안 CATS) +enum EventCategory { + hospital('병원·검진', AppColors.catHospital, RupIconName.hospital), + vaccine('예방접종', AppColors.catVaccine, RupIconName.vaccine), + grooming('미용', AppColors.catGrooming, RupIconName.grooming), + walk('산책', AppColors.catWalk, RupIconName.tree), + food('식사·간식', AppColors.catFood, RupIconName.food), + med('약·영양제', AppColors.catMed, RupIconName.pill), + etc('기타', AppColors.catEtc, RupIconName.star); + + const EventCategory(this.label, this.color, this.icon); + + final String label; + final Color color; + final RupIconName icon; + + /// 일정 추가 화면의 "예약 일정" 선택지 (시안 순서) + static const List reservationChoices = [hospital, vaccine, grooming, etc]; + + static EventCategory byName(String? name) => + EventCategory.values.where((c) => c.name == name).firstOrNull ?? etc; +} + +/// 예약 일정(병원·미용·검진 등 날짜가 정해진 약속) / 일반 일정(데일리 케어) +enum EventKind { + reservation, + general; + + static EventKind byName(String? name) => + EventKind.values.where((k) => k.name == name).firstOrNull ?? general; +} + +/// 일정 라벨 색 선택지 — 고르지 않으면 분류 기본 색 (시안 LABEL_COLORS) +const List kLabelColors = [ + Color(0xFFFF6B6B), Color(0xFFF2974E), Color(0xFFFFC94D), Color(0xFF3FB58C), + Color(0xFF4DA6E0), Color(0xFFB07CF0), Color(0xFFE86BA8), Color(0xFF9AA0A6), +]; diff --git a/flutter/lib/features/schedule/domain/repeat_rule.dart b/flutter/lib/features/schedule/domain/repeat_rule.dart new file mode 100644 index 0000000..ff69dc4 --- /dev/null +++ b/flutter/lib/features/schedule/domain/repeat_rule.dart @@ -0,0 +1,122 @@ +import 'package:tma_app/core/utils/date_format.dart'; +import 'package:tma_app/features/schedule/domain/schedule_event.dart'; + +/// 반복 방식 — 매일 / 평일만 / 주말만 / N일·N주·N개월마다 +enum RepeatPattern { daily, weekday, weekend, every } + +/// [RepeatPattern.every] 의 주기 단위 +enum RepeatUnit { + day('일'), + week('주'), + month('개월'); + + const RepeatUnit(this.label); + + final String label; +} + +/// 반복 주기 — "2주마다" +class RepeatSpec { + const RepeatSpec(this.mode, {this.unit = RepeatUnit.week, this.n = 1}); + + static const int maxN = 31; + + final RepeatPattern mode; + final RepeatUnit unit; + final int n; + + RepeatSpec copyWith({RepeatPattern? mode, RepeatUnit? unit, int? n}) => + RepeatSpec(mode ?? this.mode, unit: unit ?? this.unit, n: n ?? this.n); + + /// [from] 에서 시작한 반복이 [date] 에도 있는지 (시안 repeatMatches) + bool matches(DateTime from, DateTime date) { + final a = dateOnly(from); + final b = dateOnly(date); + if (b.isBefore(a)) return false; + final weekend = b.weekday == DateTime.saturday || b.weekday == DateTime.sunday; + switch (mode) { + case RepeatPattern.daily: + return true; + case RepeatPattern.weekday: + return !weekend; + case RepeatPattern.weekend: + return weekend; + case RepeatPattern.every: + final step = n < 1 ? 1 : n; + // 서머타임이 없는 한국 기준이지만, 날짜 차이는 UTC 로 세어 시각 차이를 피한다 + final days = DateTime.utc(b.year, b.month, b.day).difference(DateTime.utc(a.year, a.month, a.day)).inDays; + switch (unit) { + case RepeatUnit.day: + return days % step == 0; + case RepeatUnit.week: + return days % 7 == 0 && (days ~/ 7) % step == 0; + case RepeatUnit.month: + if (b.day != a.day) return false; + final months = (b.year - a.year) * 12 + (b.month - a.month); + return months % step == 0; + } + } + } + + /// "매일", "평일마다", "2주 마다" (시안 repeatLabel) + String get label => switch (mode) { + RepeatPattern.daily => '매일', + RepeatPattern.weekday => '평일마다', + RepeatPattern.weekend => '주말마다', + RepeatPattern.every => '${n < 1 ? 1 : n}${unit.label} 마다', + }; + + @override + bool operator ==(Object other) => + other is RepeatSpec && other.mode == mode && other.unit == unit && other.n == n; + + @override + int get hashCode => Object.hash(mode, unit, n); + + Map toJson() => {'mode': mode.name, 'unit': unit.name, 'n': n}; + + factory RepeatSpec.fromJson(Map json) => RepeatSpec( + RepeatPattern.values.byName(json['mode']! as String), + unit: RepeatUnit.values.byName(json['unit'] as String? ?? RepeatUnit.week.name), + n: (json['n'] as num?)?.toInt() ?? 1, + ); +} + +/// 끝나는 날 없이 반복되는 일정 — 날짜를 미리 만들지 않고 규칙으로 보관한다 +class RepeatRule { + const RepeatRule({required this.id, required this.spec, required this.from, required this.details}); + + final String id; + final RepeatSpec spec; + + /// 반복을 시작한 날 + final DateTime from; + final EventDetails details; + + bool occursOn(DateTime date) => spec.matches(from, date); + + /// [date] 에 놓이는 일정 하나 + ScheduleEvent instanceOn(DateTime date) => ScheduleEvent( + id: ScheduleEvent.instanceId(id, date), + date: dateOnly(date), + details: details, + ruleId: id, + ); + + RepeatRule copyWith({RepeatSpec? spec, EventDetails? details}) => + RepeatRule(id: id, spec: spec ?? this.spec, from: from, details: details ?? this.details); + + Map toJson() => { + 'id': id, + 'from': toIsoDate(from), + 'spec': spec.toJson(), + 'details': details.toJson(), + }; + + factory RepeatRule.fromJson(Map json) => RepeatRule( + id: json['id']! as String, + from: parseIsoDate(json['from']! as String), + spec: RepeatSpec.fromJson(json['spec']! as Map), + details: EventDetails.fromJson(json['details']! as Map), + ); +} diff --git a/flutter/lib/features/schedule/domain/schedule_book.dart b/flutter/lib/features/schedule/domain/schedule_book.dart new file mode 100644 index 0000000..7ddce45 --- /dev/null +++ b/flutter/lib/features/schedule/domain/schedule_book.dart @@ -0,0 +1,157 @@ +import 'package:tma_app/core/utils/date_format.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/domain/schedule_event.dart'; + +/// 홈 "예약 일정" 이 내다보는 기간과 개수 (시안: 90일 · 4개) +const int kUpcomingLookaheadDays = 90; +const int kUpcomingLimit = 4; + +/// 일정 전체 — 날짜가 정해진 일정 + 끝없는 반복 규칙 + 완료 표시. +/// 바꾸는 메서드는 새 객체를 돌려준다 (저장은 호출한 쪽이 한다). +class ScheduleBook { + const ScheduleBook({this.events = const [], this.rules = const [], this.doneIds = const {}}); + + static const ScheduleBook empty = ScheduleBook(); + + final List events; + final List rules; + + /// 완료한 일정 id — 반복 일정은 날짜별 id("규칙@날짜")로 따로 체크한다 + final Set doneIds; + + bool isDone(String id) => doneIds.contains(id); + + // ── 조회 ──────────────────────────────────────────────── + /// [date] 의 일정 — 시간순, 하루 종일은 뒤로 + List eventsOn(DateTime date) { + final day = dateOnly(date); + return [ + ...events.where((e) => e.date == day), + for (final r in rules) + if (r.occursOn(day)) r.instanceOn(day), + ]..sort(compareByTime); + } + + /// id 로 일정 찾기 — 반복 일정은 그 날짜의 일정으로 돌려준다 + ScheduleEvent? find(String id) { + final ruleId = ScheduleEvent.ruleIdOf(id); + if (ruleId == null) return events.where((e) => e.id == id).firstOrNull; + final rule = rules.where((r) => r.id == ruleId).firstOrNull; + if (rule == null) return null; + return rule.instanceOn(parseIsoDate(id.split('@').last)); + } + + RepeatRule? ruleOf(ScheduleEvent event) => + event.ruleId == null ? null : rules.where((r) => r.id == event.ruleId).firstOrNull; + + /// 오늘의 일반 일정(데일리 케어) + List todayTasks(DateTime today) => + eventsOn(today).where((e) => e.details.kind == EventKind.general).toList(); + + /// 다가오는 예약 일정 — 오늘부터 [kUpcomingLookaheadDays]일 안, 가까운 순 [kUpcomingLimit]개 + List upcomingReservations(DateTime today) { + final start = dateOnly(today); + final end = start.add(const Duration(days: kUpcomingLookaheadDays)); + final list = [ + ...events.where((e) => !e.date.isBefore(start)), + for (var d = start; d.isBefore(end); d = DateTime(d.year, d.month, d.day + 1)) + for (final r in rules) + if (r.occursOn(d)) r.instanceOn(d), + ].where((e) => e.details.kind == EventKind.reservation).toList() + ..sort((a, b) { + final byDate = a.date.compareTo(b.date); + return byDate != 0 ? byDate : compareByTime(a, b); + }); + return list.take(kUpcomingLimit).toList(); + } + + // ── 변경 ──────────────────────────────────────────────── + ScheduleBook copyWith({List? events, List? rules, Set? doneIds}) => + ScheduleBook( + events: events ?? this.events, + rules: rules ?? this.rules, + doneIds: doneIds ?? this.doneIds, + ); + + ScheduleBook addEvents(List list) => copyWith(events: [...events, ...list]); + + ScheduleBook addRule(RepeatRule rule) => copyWith(rules: [...rules, rule]); + + /// 일정 내용 수정 (기간 설정 없이) — 반복 여부 전환까지 처리한다 (시안 updateEvent) + /// - 반복 일정 + [spec] 있음 → 규칙의 주기·내용을 바꾼다 + /// - 반복 일정 + [spec] 없음 → 반복을 끄고 보고 있던 날짜의 일정 하나로 남긴다 + /// - 일반 일정 + [spec] 있음 → 그 날짜부터 반복 규칙으로 바꾼다 + ScheduleBook update( + ScheduleEvent target, + EventDetails details, { + RepeatSpec? spec, + required String Function() newEventId, + required String Function() newRuleId, + }) { + final ruleId = target.ruleId; + if (ruleId != null) { + if (spec != null) { + return copyWith(rules: [ + for (final r in rules) r.id == ruleId ? r.copyWith(spec: spec, details: details) : r, + ]); + } + return copyWith( + rules: rules.where((r) => r.id != ruleId).toList(), + events: [...events, ScheduleEvent(id: newEventId(), date: target.date, details: details)], + doneIds: _withoutRule(ruleId), + ); + } + if (spec != null) { + return copyWith( + events: events.where((e) => e.id != target.id).toList(), + rules: [...rules, RepeatRule(id: newRuleId(), spec: spec, from: target.date, details: details)], + doneIds: {...doneIds}..remove(target.id), + ); + } + return copyWith(events: [ + for (final e in events) e.id == target.id ? e.copyWith(details: details) : e, + ]); + } + + /// 일정(반복이면 규칙 전체)을 지우고 [list] 로 대신한다 — 기간 설정으로 바꾼 수정 + ScheduleBook replace(ScheduleEvent target, List list) => + delete(target).addEvents(list); + + /// 일정 삭제 — 반복 일정이면 규칙 전체를 지운다 + ScheduleBook delete(ScheduleEvent target) { + final ruleId = target.ruleId; + if (ruleId != null) { + return copyWith(rules: rules.where((r) => r.id != ruleId).toList(), doneIds: _withoutRule(ruleId)); + } + return copyWith( + events: events.where((e) => e.id != target.id).toList(), + doneIds: {...doneIds}..remove(target.id), + ); + } + + ScheduleBook toggleDone(String id) => + copyWith(doneIds: doneIds.contains(id) ? ({...doneIds}..remove(id)) : {...doneIds, id}); + + /// 아이를 지우면 일정에서 그 아이만 뗀다 — 대상이 남지 않은 일정은 지운다 (시안 dropPetFromEvents) + ScheduleBook dropTarget(String targetId) { + EventDetails? strip(EventDetails d) { + final rest = d.targetIds.where((id) => id != targetId).toList(); + return rest.isEmpty ? null : d.copyWith(targetIds: rest); + } + + final keptEvents = []; + for (final e in events) { + final d = strip(e.details); + if (d != null) keptEvents.add(e.copyWith(details: d)); + } + final keptRules = []; + for (final r in rules) { + final d = strip(r.details); + if (d != null) keptRules.add(r.copyWith(details: d)); + } + return copyWith(events: keptEvents, rules: keptRules); + } + + Set _withoutRule(String ruleId) => doneIds.where((id) => !id.startsWith('$ruleId@')).toSet(); +} diff --git a/flutter/lib/features/schedule/domain/schedule_event.dart b/flutter/lib/features/schedule/domain/schedule_event.dart new file mode 100644 index 0000000..d136ce4 --- /dev/null +++ b/flutter/lib/features/schedule/domain/schedule_event.dart @@ -0,0 +1,126 @@ +import 'dart:ui' show Color; + +import 'package:tma_app/core/utils/color_utils.dart'; +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'; + +/// 날짜·id 를 뺀 일정 내용 — 반복 규칙의 원본으로도 쓴다 +class EventDetails { + const EventDetails({ + required this.title, + required this.kind, + required this.category, + required this.targetIds, + this.start, + this.end, + this.place, + this.labelColor, + }); + + final String title; + final EventKind kind; + final EventCategory category; + + /// 대상 — 반려동물 id 또는 [kSelfTargetId]. 여러 마리를 한 일정에 묶는다 + final List targetIds; + + /// 시작 시각 — null 이면 하루 종일 + final HourMinute? start; + + /// 종료 시각 (선택) + final HourMinute? end; + final String? place; + + /// 직접 고른 라벨 색 — null 이면 분류 기본 색 + final Color? labelColor; + + bool get allDay => start == null; + + /// 달력 점·목록 띠에 쓰는 색 + Color get color => labelColor ?? category.color; + + EventDetails copyWith({List? targetIds}) => EventDetails( + title: title, + kind: kind, + category: category, + targetIds: targetIds ?? this.targetIds, + start: start, + end: end, + place: place, + labelColor: labelColor, + ); + + Map toJson() => { + 'title': title, + 'kind': kind.name, + 'category': category.name, + 'targets': targetIds, + 'start': start?.hhmm, + 'end': end?.hhmm, + 'place': place, + 'color': labelColor == null ? null : hexOf(labelColor!), + }; + + factory EventDetails.fromJson(Map json) => EventDetails( + title: json['title']! as String, + kind: EventKind.byName(json['kind'] as String?), + category: EventCategory.byName(json['category'] as String?), + targetIds: (json['targets'] as List?)?.whereType().toList() ?? const [], + start: HourMinute.tryParse(json['start'] as String?), + end: HourMinute.tryParse(json['end'] as String?), + place: json['place'] as String?, + labelColor: colorFromHex(json['color'] as String?), + ); +} + +/// 달력에 놓인 일정 하나 +class ScheduleEvent { + const ScheduleEvent({ + required this.id, + required this.date, + required this.details, + this.ruleId, + }); + + /// 반복 규칙에서 만든 일정의 id — "규칙id@2026-09-28" + static String instanceId(String ruleId, DateTime date) => '$ruleId@${toIsoDate(date)}'; + + /// 반복 일정 id 에서 규칙 id 를 꺼낸다 (일반 일정이면 null) + static String? ruleIdOf(String id) => id.contains('@') ? id.split('@').first : null; + + final String id; + + /// 날짜 (시각 없음) + final DateTime date; + final EventDetails details; + + /// 반복 규칙에서 만들어진 일정이면 그 규칙 id — 수정·삭제는 규칙 전체에 적용된다 + final String? ruleId; + + bool get isRepeat => ruleId != null; + + ScheduleEvent copyWith({EventDetails? details}) => + ScheduleEvent(id: id, date: date, details: details ?? this.details, ruleId: ruleId); + + Map toJson() => {'id': id, 'date': toIsoDate(date), ...details.toJson()}; + + factory ScheduleEvent.fromJson(Map json) => ScheduleEvent( + id: json['id']! as String, + date: parseIsoDate(json['date']! as String), + details: EventDetails.fromJson(json), + ); +} + +/// 하루 안 정렬 — 시간이 정해진 일정을 이른 순으로, 하루 종일은 뒤에 (시안: 시각 문자열 정렬) +int compareByTime(ScheduleEvent a, ScheduleEvent b) { + final sa = a.details.start; + final sb = b.details.start; + if (sa == null && sb == null) return 0; + if (sa == null) return 1; + if (sb == null) return -1; + return sa.compareTo(sb); +} diff --git a/flutter/lib/features/schedule/presentation/pages/event_editor_page.dart b/flutter/lib/features/schedule/presentation/pages/event_editor_page.dart new file mode 100644 index 0000000..68e655a --- /dev/null +++ b/flutter/lib/features/schedule/presentation/pages/event_editor_page.dart @@ -0,0 +1,796 @@ +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/theme/app_typography.dart'; +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_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'; +import 'package:tma_app/features/schedule/domain/repeat_rule.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_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'; +import 'package:tma_app/shared/widgets/rup_button.dart'; +import 'package:tma_app/shared/widgets/rup_dialog.dart'; +import 'package:tma_app/shared/widgets/rup_form_fields.dart'; +import 'package:tma_app/shared/widgets/rup_toast.dart'; +import 'package:tma_app/shared/widgets/sub_page_header.dart'; +import 'package:tma_app/shared/widgets/time_picker_sheet.dart'; + +const double _kNormal = AppTypography.normalHeight; + +/// 기간 설정 기본 길이 — 시작일 포함 14일 (시안 shiftYmd(+13)) +const int _kDefaultPeriodDays = 14; + +/// 빠른 기간 선택 (일수, 표기) +const List<(int, String)> _kQuickPeriods = [(7, '1주'), (14, '2주'), (30, '1개월'), (90, '3개월')]; + +/// 하루 횟수별 기본 시각 (시안 DOSE_DEFAULTS) +const Map> _kDoseDefaults = { + 1: [HourMinute(9, 0)], + 2: [HourMinute(8, 0), HourMinute(20, 0)], + 3: [HourMinute(8, 0), HourMinute(13, 0), HourMinute(20, 0)], + 4: [HourMinute(8, 0), HourMinute(12, 0), HourMinute(16, 0), HourMinute(20, 0)], +}; + +/// 기간 설정 끝 해 +const int _kLastYear = 2050; + +/// 일정 추가 / 수정 — [eventId] 가 있으면 수정 +class EventEditorPage extends ConsumerWidget { + const EventEditorPage.create({super.key, required DateTime this.date}) : eventId = null; + + const EventEditorPage.edit({super.key, required String this.eventId}) : date = null; + + final DateTime? date; + final String? eventId; + + @override + Widget build(BuildContext context, WidgetRef ref) { + // 아이 목록·일정을 다 불러온 뒤에 연다 — 불러오기 전에 열려 기본값으로 채워지지 않게 + final pets = ref.watch(petListProvider).value; + final schedule = ref.watch(scheduleProvider).value; + if (pets == null || schedule == null) return const Scaffold(body: SizedBox.shrink()); + + final id = eventId; + if (id == null) return _EventEditor(date: date!, pets: pets); + final event = schedule.find(id); + // 지워진 일정 — 빈 화면 대신 목록으로 돌아갈 수 있게 머리만 남긴다 + if (event == null) { + return Scaffold( + body: SafeArea( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: AppSpacing.xl), + child: SubPageHeader(title: '일정 수정', subtitle: '지워진 일정이에요', onBack: () => context.pop()), + ), + ), + ); + } + return _EventEditor(date: event.date, pets: pets, editing: event, rule: schedule.ruleOf(event)); + } +} + +class _EventEditor extends ConsumerStatefulWidget { + const _EventEditor({required this.date, required this.pets, this.editing, this.rule}); + + final DateTime date; + final List pets; + final ScheduleEvent? editing; + + /// 수정 중인 일정이 반복 일정이면 그 규칙 + final RepeatRule? rule; + + @override + ConsumerState<_EventEditor> createState() => _EventEditorState(); +} + +class _EventEditorState extends ConsumerState<_EventEditor> { + late final EventDetails? _orig = widget.editing?.details; + late final List _targets = [...(_orig?.targetIds ?? [widget.pets.firstOrNull?.id ?? kSelfTargetId])]; + late EventKind _kind = _orig?.kind ?? EventKind.general; + late EventCategory _category = _orig?.category ?? EventCategory.etc; + late final _title = TextEditingController(text: _orig?.title); + late final _place = TextEditingController(text: _orig?.place); + final _careInput = TextEditingController(); + bool _addingCare = false; + + // 시간 + late bool _allDay = _orig?.allDay ?? true; + late HourMinute? _start = _orig?.start; + late HourMinute? _end = _orig?.end; + + // 반복 + late RepeatSpec? _repeat = widget.rule?.spec; + late bool _customCycle = _repeat != null && _repeat!.mode == RepeatPattern.every && !RepeatOptions.isPreset(_repeat!); + + // 기간 설정 + bool _periodOn = false; + late DateTime _pStart = widget.date; + late DateTime _pEnd = _addDays(widget.date, _kDefaultPeriodDays - 1); + int _doseCount = 1; + List _doseTimes = _kDoseDefaults[1]!; + + // 라벨 색 + late Color? _labelColor = _orig?.labelColor; + bool _randomOn = false; + + bool _saving = false; + + bool get _editing => widget.editing != null; + + static DateTime _addDays(DateTime d, int days) => DateTime(d.year, d.month, d.day + days); + + @override + void initState() { + super.initState(); + _title.addListener(() => setState(() {})); + } + + @override + void dispose() { + _title.dispose(); + _place.dispose(); + _careInput.dispose(); + super.dispose(); + } + + // ── 대상 ───────────────────────────────────────────── + /// 여러 마리를 고를 수 있고, 마지막 하나는 끌 수 없다 + void _toggleTarget(String id) => setState(() { + if (!_targets.contains(id)) { + _targets.add(id); + } else if (_targets.length > 1) { + _targets.remove(id); + } + }); + + // ── 일반 일정 목록 ──────────────────────────────────── + void _pickCare(CarePreset p) { + // 이름 칸의 변경 알림이 다시 그려 준다 + _kind = EventKind.general; + _category = p.category; + _title.text = p.title; + } + + Future _removeCare(CarePreset p) async { + await ref.read(carePresetsProvider.notifier).remove(p.title); + if (_title.text == p.title) _title.clear(); + } + + Future _addCare() async { + final v = _careInput.text.trim(); + _careInput.clear(); + setState(() => _addingCare = false); + if (v.isEmpty) return; + await ref.read(carePresetsProvider.notifier).add(CarePreset(v, _category)); + if (!mounted) return; + _kind = EventKind.general; + _title.text = v; + } + + // ── 시간 ───────────────────────────────────────────── + Future _pickStart() async { + setState(() => _allDay = false); + final v = await showTimePickerSheet(context, title: '시작 시간 선택', initial: _start); + if (v == null || !mounted) return; + setState(() { + _start = v; + // 시작을 늦춰 종료가 앞서면 종료를 1분 뒤로만 맞춘다 + final end = _end; + if (end != null && end.compareTo(v) <= 0) _end = HourMinute.fromMinutes(v.totalMinutes + 1); + }); + } + + Future _pickEnd() async { + setState(() => _allDay = false); + final start = _start; + final v = await showTimePickerSheet( + context, + title: '종료 시간 선택', + initial: _end, + validate: (v) => start != null && v.compareTo(start) <= 0 ? '종료 시간은 시작(${start.label}) 이후로 선택해주세요' : null, + ); + if (v == null || !mounted) return; + setState(() => _end = v); + } + + // ── 기간 ───────────────────────────────────────────── + Future _pickPeriod({required bool start}) async { + final v = await showRupDatePicker( + context, + title: start ? '시작일 선택' : '종료일 선택', + initial: start ? _pStart : _pEnd, + firstYear: DateTime.now().year, + lastYear: _kLastYear, + ); + if (v == null || !mounted) return; + setState(() { + if (start) { + _pStart = v; + if (v.isAfter(_pEnd)) _pEnd = _addDays(v, _kDefaultPeriodDays - 1); + } else { + _pEnd = v; + } + }); + } + + Future _pickDose(int i) async { + final v = await showTimePickerSheet(context, initial: _doseTimes[i]); + if (v == null || !mounted) return; + setState(() => _doseTimes = [for (final (k, t) in _doseTimes.indexed) k == i ? v : t]); + } + + int get _dayCount { + final days = DateTime.utc(_pEnd.year, _pEnd.month, _pEnd.day) + .difference(DateTime.utc(_pStart.year, _pStart.month, _pStart.day)) + .inDays; + return days < 0 ? 1 : days + 1; + } + + // ── 라벨 색 ─────────────────────────────────────────── + Future _pickCustomColor() async { + final c = await showColorPickSheet(context, title: '라벨 색', initial: _labelColor ?? _category.color); + if (c == null || !mounted) return; + setState(() { + _labelColor = c; + _randomOn = false; + }); + } + + // ── 저장 ───────────────────────────────────────────── + EventDetails _details({HourMinute? startOverride, bool dropEnd = false}) { + final start = _allDay ? null : (startOverride ?? _start); + final place = _place.text.trim(); + return EventDetails( + title: _title.text.trim(), + kind: _kind, + category: _category, + targetIds: List.of(_targets), + start: start, + // 시작 없이 종료만 있는 일정은 만들지 않는다 + end: start == null || dropEnd ? null : _end, + place: place.isEmpty ? null : place, + labelColor: _labelColor, + ); + } + + /// 기간 설정 — 기간 안의 날짜(반복이 켜져 있으면 그 주기에 맞는 날만) × 하루 횟수 + List _periodDrafts() { + final lo = _pStart.isAfter(_pEnd) ? _pEnd : _pStart; + final hi = _pStart.isAfter(_pEnd) ? _pStart : _pEnd; + final repeat = _repeat; + final multiDose = _doseCount > 1; + return [ + for (var d = lo; !d.isAfter(hi); d = _addDays(d, 1)) + if (repeat == null || repeat.matches(lo, d)) + for (final t in multiDose ? _doseTimes : [null]) + (date: d, details: _details(startOverride: t, dropEnd: multiDose)), + ]; + } + + Future _submit() async { + if (_saving) return; + final toast = ref.read(toastProvider.notifier); + if (_title.text.trim().isEmpty) { + toast.show('일정 이름을 입력해주세요', icon: RupIconName.alert); + return; + } + final notifier = ref.read(scheduleProvider.notifier); + final editing = widget.editing; + setState(() => _saving = true); + try { + if (_periodOn) { + final drafts = _periodDrafts(); + if (drafts.isEmpty) { + toast.show('기간 안에 반복에 맞는 날짜가 없어요', icon: RupIconName.alert); + return; + } + if (editing != null) { + await notifier.replace(editing, drafts); + toast.show('일정을 수정했어요'); + } else { + await notifier.addEvents(drafts); + toast.show(drafts.length > 1 ? '여러 날짜에 일정을 추가했어요' : '일정을 추가했어요'); + } + } else if (editing != null) { + await notifier.edit(editing, _details(), spec: _repeat); + toast.show('일정을 수정했어요'); + } else if (_repeat != null) { + // 끝나는 날 없는 반복 — 규칙으로 저장해 계속 표시된다 + await notifier.addRule(widget.date, _repeat!, _details()); + toast.show('여러 날짜에 일정을 추가했어요'); + } else { + await notifier.addEvents([(date: widget.date, details: _details())]); + toast.show('일정을 추가했어요'); + } + if (mounted) context.pop(); + } finally { + if (mounted) setState(() => _saving = false); + } + } + + Future _delete() async { + final editing = widget.editing; + if (editing == null) return; + final ok = await showRupConfirm( + context, + title: '일정을 제거할까요?', + message: editing.isRepeat ? '반복으로 등록된 일정 전체가 함께 제거돼요.' : '제거한 일정은 되돌릴 수 없어요.', + confirmLabel: '제거하기', + destructive: true, + layout: RupConfirmLayout.row, + ); + if (!ok || !mounted) return; + // 먼저 화면을 닫아 지워진 일정을 그리지 않게 한다 + context.pop(); + await ref.read(scheduleProvider.notifier).delete(editing); + ref.read(toastProvider.notifier).show(editing.isRepeat ? '반복 일정을 삭제했어요' : '일정을 삭제했어요'); + } + + // ── 화면 ───────────────────────────────────────────── + @override + Widget build(BuildContext context) { + final names = ref.watch(targetNamesProvider); + final cares = ref.watch(carePresetsProvider); + final hasTitle = _title.text.trim().isNotEmpty; + final editing = widget.editing; + final subtitle = '${formatMonthDay(widget.date)}${editing != null && editing.isRepeat ? ' · 반복 일정 전체에 적용' : ''}'; + + return Scaffold( + body: SafeArea( + bottom: false, + child: SingleChildScrollView( + keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag, + padding: EdgeInsets.fromLTRB( + AppSpacing.xl, + AppSpacing.xs, + AppSpacing.xl, + MediaQuery.paddingOf(context).bottom + AppSpacing.md, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + SubPageHeader( + title: _editing ? '일정 수정' : '일정 추가', + subtitle: subtitle, + onBack: () => context.pop(), + ), + const SizedBox(height: 6), + EditorField( + label: '누구의 일정인가요?', + hint: '여러 마리 선택 가능', + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + clipBehavior: Clip.none, + padding: const EdgeInsets.only(top: 2, bottom: 6), + child: Row( + children: [ + for (final (i, id) in [kSelfTargetId, ...widget.pets.map((p) => p.id)].indexed) ...[ + if (i > 0) const SizedBox(width: 10), + TargetChip( + targetId: id, + name: names[id] ?? '', + selected: _targets.contains(id), + onTap: () => _toggleTarget(id), + ), + ], + ], + ), + ), + ), + EditorField( + label: '예약 일정', + hint: '병원·미용·검진 등', + child: CategoryGrid( + selected: _kind == EventKind.reservation ? _category : null, + onSelect: (c) => setState(() { + _kind = EventKind.reservation; + _category = c; + }), + ), + ), + EditorField( + label: '일반 일정', + hint: '자주 쓰는 일정을 간편하게', + child: Wrap( + spacing: AppSpacing.sm, + runSpacing: AppSpacing.sm, + children: [ + for (final p in cares) + RupTagChip( + label: p.title, + selected: _kind == EventKind.general && _title.text == p.title, + onTap: () => _pickCare(p), + onDelete: () => unawaited(_removeCare(p)), + largeDelete: true, + ), + if (_addingCare) + RupInputChip( + controller: _careInput, + maxLength: kCarePresetMaxLength, + onSubmit: () => unawaited(_addCare()), + ) + else + RupAddChip(label: '직접 추가', onTap: () => setState(() => _addingCare = true)), + ], + ), + ), + EditorField(child: _detailsBox()), + EditorField( + child: ExpandPanel( + icon: RupIconName.repeat, + title: '반복 일정', + subtitle: _repeat == null ? '반복되는 일정에 활용해보세요' : '${_repeat!.label} 반복', + on: _repeat != null, + onChanged: (v) => setState(() { + _repeat = v ? const RepeatSpec(RepeatPattern.daily) : null; + _customCycle = false; + }), + child: RepeatOptions( + spec: _repeat ?? const RepeatSpec(RepeatPattern.daily), + custom: _customCycle, + onSpec: (s) => setState(() => _repeat = s), + onCustom: (v) => setState(() => _customCycle = v), + ), + ), + ), + EditorField( + child: ExpandPanel( + icon: RupIconName.pill, + title: '기간 설정', + subtitle: '시작일과 종료일이 있는 일정에 활용해보세요', + on: _periodOn, + onChanged: (v) => setState(() => _periodOn = v), + child: _periodBody(), + ), + ), + EditorField( + label: '라벨 색', + hint: '달력에 표시되는 색', + child: LabelColorPicker( + color: _labelColor, + fallback: _category.color, + random: _randomOn, + onPick: (c) => setState(() { + _labelColor = c; + _randomOn = false; + }), + onRandom: () => setState(() { + _labelColor = LabelColorPicker.randomColor(); + _randomOn = true; + }), + onCustom: () => unawaited(_pickCustomColor()), + ), + ), + const SizedBox(height: 18), + RupButton( + label: _editing ? '수정 완료' : '일정 저장', + large: true, + thin: true, + dimmed: !hasTitle, + onPressed: () => unawaited(_submit()), + ), + if (_editing) ...[ + const SizedBox(height: 22), + _TextAction(label: '일정 제거', onTap: () => unawaited(_delete())), + ], + const SizedBox(height: AppSpacing.md), + ], + ), + ), + ), + ); + } + + /// 일정 이름 · 시간(하루 종일/시작/종료) · 장소를 한 상자에 + Widget _detailsBox() { + const divider = BorderSide(color: AppColors.line, width: 1.5); + final valueStyle = _uiStyle(15.5, FontWeight.w700, AppColors.ink); + Widget timeRow(String label, HourMinute? value, VoidCallback onTap) => Semantics( + button: true, + label: '$label 시간', + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: onTap, + child: AnimatedOpacity( + duration: const Duration(milliseconds: 180), + opacity: _allDay ? .38 : 1, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + child: Row( + children: [ + Expanded(child: Text(label, style: _uiStyle(14.5, FontWeight.w700, AppColors.ink2))), + Text( + _allDay ? kAllDayLabel : (value?.label ?? '선택'), + style: valueStyle.copyWith(color: value == null ? AppColors.muted : AppColors.ink), + ), + ], + ), + ), + ), + ), + ); + + return Container( + decoration: BoxDecoration( + color: AppColors.surface, + borderRadius: BorderRadius.circular(AppSpacing.lg), + border: Border.all(color: AppColors.line, width: 1.5), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _BareInput(controller: _title, hint: '일정 이름을 입력해주세요'), + Container( + padding: const EdgeInsets.symmetric(vertical: 14), + decoration: const BoxDecoration(border: Border(top: divider)), + child: Column( + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + child: Row( + children: [ + RupIcon(RupIconName.clock, size: 18, color: _allDay ? AppColors.primary : AppColors.muted), + const SizedBox(width: AppSpacing.sm), + Expanded(child: Text(kAllDayLabel, style: _uiStyle(14.5, FontWeight.w700, AppColors.ink))), + HeartToggle(value: _allDay, onChanged: (v) => setState(() => _allDay = v), label: kAllDayLabel), + ], + ), + ), + const SizedBox(height: AppSpacing.xs), + timeRow('시작', _start, () => unawaited(_pickStart())), + const SizedBox(height: AppSpacing.xs), + timeRow('종료', _end, () => unawaited(_pickEnd())), + ], + ), + ), + Container( + decoration: const BoxDecoration(border: Border(top: divider)), + child: _BareInput(controller: _place, hint: '장소를 입력해주세요'), + ), + ], + ), + ); + } + + Widget _periodBody() { + final today = dateOnly(DateTime.now()); + final repeat = _repeat; + final countSuffix = switch (repeat?.mode) { + RepeatPattern.weekday => ' 중 평일', + RepeatPattern.weekend => ' 중 주말', + RepeatPattern.every => ' 중 ${repeat!.label}', + _ => '', + }; + + Widget dateBox(String label, DateTime value, VoidCallback onTap) => Expanded( + child: Semantics( + button: true, + label: '$label ${formatYmd(value)}', + excludeSemantics: true, + child: GestureDetector( + onTap: onTap, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: AppSpacing.md, vertical: 10), + decoration: BoxDecoration( + color: AppColors.surface, + borderRadius: BorderRadius.circular(11), + border: Border.all(color: AppColors.line, width: 1.5), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: _uiStyle(11, FontWeight.w700, AppColors.muted)), + const SizedBox(height: 3), + Row( + 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)), + ], + ), + ], + ), + ), + ), + ), + ); + + return Padding( + padding: const EdgeInsets.fromLTRB(13, 13, 13, 14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + for (final (i, (n, label)) in _kQuickPeriods.indexed) ...[ + if (i > 0) const SizedBox(width: 6), + Expanded( + child: OptionTile( + label: label, + fontSize: 12.5, + radius: 9, + padding: const EdgeInsets.symmetric(horizontal: 2, vertical: 7), + on: _pStart == today && _pEnd == _addDays(today, n - 1), + onTap: () => setState(() { + _pStart = today; + _pEnd = _addDays(today, n - 1); + }), + ), + ), + ], + ], + ), + const SizedBox(height: 9), + Row( + children: [ + dateBox('시작일', _pStart, () => unawaited(_pickPeriod(start: true))), + const SizedBox(width: AppSpacing.sm), + dateBox('종료일', _pEnd, () => unawaited(_pickPeriod(start: false))), + ], + ), + const SizedBox(height: AppSpacing.md), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const RupIcon(RupIconName.calendar, size: 14, stroke: 2, color: AppColors.muted), + const SizedBox(width: 6), + Text.rich( + TextSpan(children: [ + const TextSpan(text: '총 '), + TextSpan( + text: '$_dayCount일', + style: const TextStyle(color: AppColors.primary600, fontWeight: FontWeight.w800), + ), + TextSpan(text: countSuffix), + ]), + style: _uiStyle(12.5, FontWeight.w400, AppColors.ink2), + ), + ], + ), + const SizedBox(height: 15), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 2), + child: Text('하루 횟수', style: _uiStyle(11.5, FontWeight.w700, AppColors.muted)), + ), + const SizedBox(height: 7), + Row( + children: [ + for (var n = 1; n <= 4; n++) ...[ + if (n > 1) const SizedBox(width: 6), + Expanded( + child: OptionTile( + label: '$n회', + fontSize: 13, + radius: 10, + on: _doseCount == n, + onTap: () => setState(() { + _doseCount = n; + _doseTimes = _kDoseDefaults[n]!; + }), + ), + ), + ], + ], + ), + if (_doseCount > 1) ...[ + const SizedBox(height: AppSpacing.md), + for (final (i, t) in _doseTimes.indexed) ...[ + if (i > 0) const SizedBox(height: 7), + Semantics( + button: true, + label: '${i + 1}회차 ${t.label}', + excludeSemantics: true, + child: GestureDetector( + onTap: () => unawaited(_pickDose(i)), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 13, vertical: 11), + decoration: BoxDecoration( + color: AppColors.surface, + borderRadius: BorderRadius.circular(11), + border: Border.all(color: AppColors.line, width: 1.5), + ), + child: Row( + children: [ + Expanded(child: Text('${i + 1}회차', style: _uiStyle(12.5, FontWeight.w700, AppColors.muted))), + const RupIcon(RupIconName.clock, size: 15, stroke: 2, color: AppColors.primary), + const SizedBox(width: 3), + Text( + t.label, + style: _uiStyle(15, FontWeight.w700, AppColors.ink) + .copyWith(fontFeatures: const [FontFeature.tabularFigures()]), + ), + ], + ), + ), + ), + ), + ], + ] else ...[ + const SizedBox(height: 10), + Text.rich( + const TextSpan(children: [ + TextSpan(text: '복용 시간은 위 '), + TextSpan(text: '시간', style: TextStyle(fontWeight: FontWeight.w700, color: AppColors.ink2)), + TextSpan(text: ' 설정을 따라요'), + ]), + textAlign: TextAlign.center, + style: _uiStyle(11.5, FontWeight.w400, AppColors.muted), + ), + ], + ], + ), + ); + } + + TextStyle _uiStyle(double size, FontWeight weight, Color color) => Theme.of(context) + .textTheme + .bodyMedium! + .copyWith(fontSize: size, fontWeight: weight, color: color, height: _kNormal); +} + +/// 테두리 없는 입력 — 일정 이름·장소 (상자가 테두리를 그린다) +class _BareInput extends StatelessWidget { + const _BareInput({required this.controller, required this.hint}); + + final TextEditingController controller; + final String hint; + + @override + Widget build(BuildContext context) { + final style = Theme.of(context).textTheme.bodyLarge!.copyWith(fontSize: 15, height: 1.15); + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 19), + child: TextField( + controller: controller, + style: style, + cursorColor: AppColors.primary, + textInputAction: TextInputAction.done, + decoration: InputDecoration.collapsed(hintText: hint, hintStyle: style.copyWith(color: AppColors.muted)), + ), + ); + } +} + +class _TextAction extends StatelessWidget { + const _TextAction({required this.label, required this.onTap}); + + final String label; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return Semantics( + button: true, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: onTap, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: AppSpacing.sm), + child: Text( + label, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodyMedium!.copyWith( + fontSize: 13.5, + fontWeight: FontWeight.w700, + color: AppColors.muted, + height: _kNormal, + ), + ), + ), + ), + ); + } +} diff --git a/flutter/lib/features/schedule/presentation/providers/schedule_providers.dart b/flutter/lib/features/schedule/presentation/providers/schedule_providers.dart new file mode 100644 index 0000000..cba2468 --- /dev/null +++ b/flutter/lib/features/schedule/presentation/providers/schedule_providers.dart @@ -0,0 +1,101 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:tma_app/core/utils/date_format.dart'; +import 'package:tma_app/features/schedule/data/schedule_repository.dart'; +import 'package:tma_app/features/schedule/domain/care_preset.dart'; +import 'package:tma_app/features/schedule/domain/repeat_rule.dart'; +import 'package:tma_app/features/schedule/domain/schedule_book.dart'; +import 'package:tma_app/features/schedule/domain/schedule_event.dart'; + +/// 새로 만들 일정 한 건 — 날짜와 내용 (id 는 저장할 때 붙인다) +typedef EventDraft = ({DateTime date, EventDetails details}); + +/// 일정 전체 (날짜 일정 + 반복 규칙 + 완료 표시) +class ScheduleNotifier extends AsyncNotifier { + ScheduleRepository get _repo => ref.read(scheduleRepositoryProvider); + int _serial = 0; + + @override + Future build() => ref.watch(scheduleRepositoryProvider).fetch(); + + ScheduleBook get _book => state.value ?? ScheduleBook.empty; + + /// 같은 순간에 여러 건을 만들어도 겹치지 않는 id + String _newId(String prefix) => '$prefix${DateTime.now().microsecondsSinceEpoch}_${_serial++}'; + + List _materialize(List drafts) => [ + for (final d in drafts) ScheduleEvent(id: _newId('e'), date: dateOnly(d.date), details: d.details), + ]; + + Future _commit(ScheduleBook next) async { + await _repo.saveEvents(next.events); + await _repo.saveRules(next.rules); + await _repo.saveDone(next.doneIds); + if (ref.mounted) state = AsyncData(next); + } + + Future addEvents(List drafts) => _commit(_book.addEvents(_materialize(drafts))); + + /// 끝나는 날 없는 반복 일정 + Future addRule(DateTime from, RepeatSpec spec, EventDetails details) => _commit( + _book.addRule(RepeatRule(id: _newId('r'), spec: spec, from: dateOnly(from), details: details)), + ); + + Future edit(ScheduleEvent target, EventDetails details, {RepeatSpec? spec}) => _commit( + _book.update( + target, + details, + spec: spec, + newEventId: () => _newId('e'), + newRuleId: () => _newId('r'), + ), + ); + + /// 기간 설정으로 바꾼 수정 — 원래 일정(반복이면 규칙 전체)을 새 목록으로 대신한다 + Future replace(ScheduleEvent target, List drafts) => + _commit(_book.replace(target, _materialize(drafts))); + + Future delete(ScheduleEvent target) => _commit(_book.delete(target)); + + Future toggleDone(String id) => _commit(_book.toggleDone(id)); + + /// 아이를 지우면 일정에서 그 아이만 뗀다 + Future dropTarget(String targetId) => _commit(_book.dropTarget(targetId)); +} + +final scheduleProvider = AsyncNotifierProvider(ScheduleNotifier.new); + +/// 불러오기 전에도 쓸 수 있는 일정 (빈 목록으로 시작) +final scheduleBookProvider = Provider((ref) { + return ref.watch(scheduleProvider).value ?? ScheduleBook.empty; +}); + +/// 일정 관리 달력에서 보고 있는 날짜 — 일정을 추가하고 돌아와도 유지한다 +class ScheduleDateNotifier extends Notifier { + @override + DateTime build() => dateOnly(DateTime.now()); + + void select(DateTime date) => state = dateOnly(date); +} + +final scheduleDateProvider = NotifierProvider(ScheduleDateNotifier.new); + +/// 자주 쓰는 일반 일정 목록 +class CarePresetNotifier extends Notifier> { + @override + List build() => ref.watch(carePresetRepositoryProvider).load(); + + Future _save(List next) async { + state = next; + await ref.read(carePresetRepositoryProvider).save(next); + } + + /// 같은 이름이 이미 있으면 그대로 둔다 + Future add(CarePreset preset) async { + if (state.any((p) => p.title == preset.title)) return; + await _save([...state, preset]); + } + + Future remove(String title) => _save(state.where((p) => p.title != title).toList()); +} + +final carePresetsProvider = NotifierProvider>(CarePresetNotifier.new); diff --git a/flutter/lib/features/schedule/presentation/widgets/event_editor_parts.dart b/flutter/lib/features/schedule/presentation/widgets/event_editor_parts.dart new file mode 100644 index 0000000..a2f7a6a --- /dev/null +++ b/flutter/lib/features/schedule/presentation/widgets/event_editor_parts.dart @@ -0,0 +1,668 @@ +import 'dart:math'; + +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/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'; + +const double _kNormal = AppTypography.normalHeight; + +/// 접었다 펴는 패널 애니메이션 (시안 max-height .28s) +const Duration _kExpand = Duration(milliseconds: 280); + +TextStyle _ui(BuildContext context, double size, FontWeight weight, Color color) => + Theme.of(context).textTheme.bodyMedium!.copyWith(fontSize: size, fontWeight: weight, color: color, height: _kNormal); + +/// 일정 추가 화면의 항목 — 라벨(+흐린 보조 설명) + 내용 (.rup-addev .rup-field) +class EditorField extends StatelessWidget { + const EditorField({super.key, this.label, this.hint, required this.child}); + + final String? label; + final String? hint; + final Widget child; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(bottom: 26), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (label != null) + Padding( + padding: const EdgeInsets.only(bottom: 9), + child: Text.rich( + TextSpan( + text: label, + children: [ + if (hint != null) + TextSpan( + text: ' · $hint', + style: const TextStyle(color: AppColors.muted, fontWeight: FontWeight.w500), + ), + ], + ), + style: _ui(context, 13, FontWeight.w700, AppColors.ink2).copyWith(height: 1.2), + ), + ), + child, + ], + ), + ); + } +} + +/// 누구의 일정인지 고르는 알약 (시안 .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}); + + /// 예약 일정이 아니면 null + final EventCategory? selected; + final ValueChanged onSelect; + + @override + Widget build(BuildContext context) { + return Row( + children: [ + for (final (i, c) in EventCategory.reservationChoices.indexed) ...[ + if (i > 0) const SizedBox(width: AppSpacing.sm), + Expanded(child: _CategoryButton(category: c, selected: c == selected, onTap: () => onSelect(c))), + ], + ], + ); + } +} + +class _CategoryButton extends StatelessWidget { + const _CategoryButton({required this.category, required this.selected, required this.onTap}); + + final EventCategory category; + final bool selected; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final color = selected ? category.color : AppColors.ink2; + return Semantics( + button: true, + selected: selected, + label: category.label, + excludeSemantics: true, + child: GestureDetector( + onTap: onTap, + child: AnimatedContainer( + duration: AppDurations.fast, + padding: const EdgeInsets.symmetric(horizontal: 2, vertical: 11), + decoration: BoxDecoration( + color: selected ? category.color.withValues(alpha: .08) : AppColors.surface, + borderRadius: BorderRadius.circular(13), + border: Border.all(color: selected ? category.color : AppColors.line, width: 1.5), + ), + child: Column( + children: [ + RupIcon(category.icon, size: 19, stroke: 2, color: color), + const SizedBox(height: 6), + Text( + category.label, + maxLines: 1, + style: _ui(context, 11.5, FontWeight.w700, color), + ), + ], + ), + ), + ), + ); + } +} + +/// 켜면 아래로 펼쳐지는 설정 상자 — 반복 일정 · 기간 설정 +class ExpandPanel extends StatelessWidget { + const ExpandPanel({ + super.key, + required this.icon, + required this.title, + required this.subtitle, + required this.on, + required this.onChanged, + required this.child, + }); + + final RupIconName icon; + final String title; + final String subtitle; + final bool on; + final ValueChanged onChanged; + final Widget child; + + @override + Widget build(BuildContext context) { + return AnimatedContainer( + duration: const Duration(milliseconds: 180), + decoration: BoxDecoration( + color: AppColors.surface, + borderRadius: BorderRadius.circular(14), + border: Border.all(color: on ? AppColors.primary100 : AppColors.line, width: 1.5), + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(12.5), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => onChanged(!on), + child: AnimatedContainer( + duration: const Duration(milliseconds: 180), + color: on ? AppColors.surface2 : AppColors.surface, + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 13), + child: Row( + children: [ + RupIcon(icon, size: 19, stroke: 2, color: on ? AppColors.primary : AppColors.muted), + const SizedBox(width: 11), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, style: _ui(context, 14.5, FontWeight.w700, AppColors.ink)), + const SizedBox(height: 1), + Text(subtitle, style: _ui(context, 11.5, FontWeight.w400, AppColors.muted)), + ], + ), + ), + HeartToggle(value: on, onChanged: onChanged, label: title), + ], + ), + ), + ), + AnimatedSize( + duration: _kExpand, + curve: Curves.easeOut, + alignment: Alignment.topCenter, + child: on + ? DecoratedBox( + decoration: const BoxDecoration( + color: AppColors.surface2, + border: Border(top: BorderSide(color: AppColors.line)), + ), + child: child, + ) + : const SizedBox(width: double.infinity), + ), + ], + ), + ), + ); + } +} + +/// 작은 선택 칸 — 켜지면 흰 바탕 + 주황 테두리 (반복 방식·빠른 기간·하루 횟수) +class OptionTile extends StatelessWidget { + const OptionTile({ + super.key, + required this.label, + required this.on, + required this.onTap, + this.sub, + this.fontSize = 13.5, + this.padding = const EdgeInsets.symmetric(horizontal: 2, vertical: 9), + this.radius = 11, + this.onBackground = AppColors.surface, + }); + + final String label; + final String? sub; + final bool on; + final VoidCallback onTap; + final double fontSize; + final EdgeInsets padding; + final double radius; + final Color onBackground; + + @override + Widget build(BuildContext context) { + return Semantics( + button: true, + selected: on, + child: GestureDetector( + onTap: onTap, + child: AnimatedContainer( + duration: const Duration(milliseconds: 150), + padding: padding, + decoration: BoxDecoration( + color: on ? onBackground : Colors.transparent, + borderRadius: BorderRadius.circular(radius), + border: Border.all(color: on ? AppColors.primary100 : AppColors.line, width: 1.5), + ), + child: Column( + children: [ + Text(label, style: _ui(context, fontSize, FontWeight.w700, on ? AppColors.primary600 : AppColors.ink2)), + if (sub != null) ...[ + const SizedBox(height: 1), + Text(sub!, style: _ui(context, 10.5, FontWeight.w400, on ? AppColors.primary600 : AppColors.muted)), + ], + ], + ), + ), + ), + ); + } +} + +/// 반복 방식 고르기 — 프리셋 6개 + 주기 직접 설정(N일/N주/N개월) +class RepeatOptions extends StatelessWidget { + const RepeatOptions({ + super.key, + required this.spec, + required this.custom, + required this.onSpec, + required this.onCustom, + }); + + final RepeatSpec spec; + + /// 주기 직접 설정 칸이 열려 있는지 + final bool custom; + final ValueChanged onSpec; + final ValueChanged onCustom; + + static const _presets = <(String, String, RepeatSpec)>[ + ('매일', '매일', RepeatSpec(RepeatPattern.daily)), + ('평일만', '월~금', RepeatSpec(RepeatPattern.weekday)), + ('주말만', '토·일', RepeatSpec(RepeatPattern.weekend)), + ('매주', '1주 마다', RepeatSpec(RepeatPattern.every)), + ('격주', '2주 마다', RepeatSpec(RepeatPattern.every, n: 2)), + ('매월', '1개월 마다', RepeatSpec(RepeatPattern.every, unit: RepeatUnit.month)), + ]; + + /// 직접 설정이 필요 없는 주기인지 — 편집할 때 직접 설정 칸을 열지 정한다 + static bool isPreset(RepeatSpec spec) => _presets.any((p) => p.$3 == spec); + + @override + Widget build(BuildContext context) { + Widget preset(int i) { + final (label, sub, s) = _presets[i]; + final on = s.mode == RepeatPattern.every ? !custom && spec == s : spec.mode == s.mode; + return OptionTile( + label: label, + sub: sub, + on: on, + onTap: () { + onCustom(false); + onSpec(s); + }, + ); + } + + Widget row(int from) => Row( + children: [ + for (var i = from; i < from + 3; i++) ...[ + if (i > from) const SizedBox(width: 7), + Expanded(child: preset(i)), + ], + ], + ); + + final accent = _ui(context, 12.5, FontWeight.w700, AppColors.primary600); + return Padding( + padding: const EdgeInsets.fromLTRB(11, 11, 11, 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + row(0), + const SizedBox(height: 7), + row(3), + const SizedBox(height: 7), + Semantics( + button: true, + child: GestureDetector( + onTap: () { + if (custom) { + onCustom(false); + } else { + onCustom(true); + if (spec.mode != RepeatPattern.every) onSpec(spec.copyWith(mode: RepeatPattern.every)); + } + }, + child: DashedBorderBox( + color: custom ? AppColors.primary100 : AppColors.line, + radius: 11, + backgroundColor: custom ? AppColors.surface : null, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 2, vertical: 10.5), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + RupIcon(custom ? RupIconName.check : RupIconName.plus, size: 13, stroke: 2.4, color: AppColors.primary600), + const SizedBox(width: 5), + Text(custom ? '${spec.label} 반복' : '주기 직접 설정', style: accent), + ], + ), + ), + ), + ), + ), + if (custom) ...[ + const SizedBox(height: AppSpacing.sm), + _CycleEditor(spec: spec, onSpec: onSpec), + ], + ], + ), + ); + } +} + +/// N — 일/주/개월 마다 +class _CycleEditor extends StatelessWidget { + const _CycleEditor({required this.spec, required this.onSpec}); + + final RepeatSpec spec; + final ValueChanged onSpec; + + @override + Widget build(BuildContext context) { + Widget step(RupIconName icon, String label, bool enabled, int delta) => Semantics( + button: true, + enabled: enabled, + label: label, + excludeSemantics: true, + child: GestureDetector( + onTap: enabled ? () => onSpec(spec.copyWith(n: spec.n + delta)) : null, + child: SizedBox.square( + dimension: 26, + child: Center( + child: Opacity( + opacity: enabled ? .85 : .35, + child: RupIcon(icon, size: 15, stroke: 2.8, color: AppColors.primary600), + ), + ), + ), + ), + ); + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 11, vertical: 10), + decoration: BoxDecoration( + color: AppColors.surface, + borderRadius: BorderRadius.circular(11), + border: Border.all(color: AppColors.line, width: 1.5), + ), + child: Row( + children: [ + step(RupIconName.minus, '주기 줄이기', spec.n > 1, -1), + const SizedBox(width: AppSpacing.xs), + SizedBox( + width: 20, + child: Text( + '${spec.n}', + textAlign: TextAlign.center, + style: _ui(context, 15.5, FontWeight.w800, AppColors.ink) + .copyWith(fontFeatures: const [FontFeature.tabularFigures()]), + ), + ), + const SizedBox(width: AppSpacing.xs), + step(RupIconName.plus, '주기 늘리기', spec.n < RepeatSpec.maxN, 1), + const SizedBox(width: AppSpacing.sm), + for (final (i, u) in RepeatUnit.values.indexed) ...[ + if (i > 0) const SizedBox(width: 5), + Expanded( + child: OptionTile( + label: '${u.label} 마다', + on: spec.unit == u, + fontSize: 12.5, + padding: const EdgeInsets.symmetric(horizontal: 2, vertical: 7), + radius: 9, + onBackground: AppColors.surface2, + onTap: () => onSpec(spec.copyWith(mode: RepeatPattern.every, unit: u)), + ), + ), + ], + ], + ), + ); + } +} + +/// 라벨 색 — 기본 8색 · 랜덤 · 직접 지정 +class LabelColorPicker extends StatelessWidget { + const LabelColorPicker({ + super.key, + required this.color, + required this.fallback, + required this.random, + required this.onPick, + required this.onRandom, + required this.onCustom, + }); + + /// 고른 색 — null 이면 분류 기본 색 [fallback] + final Color? color; + final Color fallback; + + /// 지금 색이 랜덤으로 뽑힌 것인지 + final bool random; + final ValueChanged onPick; + final VoidCallback onRandom; + final VoidCallback onCustom; + + bool get _isCustom => color != null && !kLabelColors.contains(color); + + /// 달력에서 잘 보이도록 채도·밝기를 좁게 잡은 랜덤 색 (시안 pickRandomColor) + static Color randomColor([Random? rng]) { + final r = rng ?? Random(); + return HSLColor.fromAHSL(1, r.nextInt(360).toDouble(), (62 + r.nextInt(18)) / 100, (52 + r.nextInt(10)) / 100) + .toColor(); + } + + @override + Widget build(BuildContext context) { + final shown = color ?? fallback; + final muted = _ui(context, 11.5, FontWeight.w400, AppColors.muted); + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 11, vertical: 10), + decoration: BoxDecoration( + color: AppColors.surface, + borderRadius: BorderRadius.circular(AppSpacing.lg), + border: Border.all(color: AppColors.line, width: 1.5), + ), + child: Row( + children: [ + Expanded( + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + for (final c in kLabelColors) + _Swatch(color: c, on: color == c && !random, onTap: () => onPick(c)), + ], + ), + ), + const SizedBox(width: AppSpacing.sm), + const SizedBox(width: 1, height: 20, child: ColoredBox(color: AppColors.line)), + const SizedBox(width: AppSpacing.sm), + _RandomButton(on: random, color: shown, onTap: onRandom), + const SizedBox(width: AppSpacing.sm), + _CustomSwatch(color: _isCustom && !random ? color : null, onTap: onCustom), + ], + ), + ), + const SizedBox(height: 7), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + if (random || _isCustom) ...[ + Text(random ? '랜덤 색상' : '직접 지정', style: muted), + const SizedBox(width: 6), + ], + Text(hexOf(shown), style: muted), + const SizedBox(width: 6), + Container(width: 11, height: 11, decoration: BoxDecoration(color: shown, shape: BoxShape.circle)), + ], + ), + ], + ); + } +} + +class _Swatch extends StatelessWidget { + const _Swatch({required this.color, required this.on, required this.onTap}); + + final Color color; + final bool on; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return Semantics( + button: true, + selected: on, + label: hexOf(color), + excludeSemantics: true, + child: GestureDetector( + onTap: onTap, + child: AnimatedContainer( + duration: const Duration(milliseconds: 150), + width: 22, + height: 22, + decoration: BoxDecoration( + color: color, + shape: BoxShape.circle, + border: Border.all(color: on ? AppColors.surface : Colors.transparent, width: 2), + boxShadow: on ? [BoxShadow(color: color, spreadRadius: 2)] : null, + ), + ), + ), + ); + } +} + +class _RandomButton extends StatelessWidget { + const _RandomButton({required this.on, required this.color, required this.onTap}); + + final bool on; + final Color color; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return Semantics( + button: true, + label: '랜덤 색', + excludeSemantics: true, + child: GestureDetector( + onTap: onTap, + child: Container( + padding: const EdgeInsets.fromLTRB(5, 4, 9, 4), + decoration: BoxDecoration( + color: on ? AppColors.surface2 : Colors.transparent, + borderRadius: BorderRadius.circular(AppRadius.pill), + border: Border.all(color: on ? AppColors.primary100 : AppColors.line, width: 1.5), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 18, + height: 18, + decoration: BoxDecoration(color: on ? color : Colors.transparent, shape: BoxShape.circle), + alignment: Alignment.center, + child: RupIcon(RupIconName.dice, size: 14, stroke: 1.9, color: on ? Colors.white : AppColors.ink2), + ), + const SizedBox(width: 5), + Text('랜덤', style: _ui(context, 12, FontWeight.w700, on ? AppColors.primary600 : AppColors.ink2)), + ], + ), + ), + ), + ); + } +} + +/// 색 직접 지정 — 무지개 원(+), 지정한 색이 있으면 그 색 +class _CustomSwatch extends StatelessWidget { + const _CustomSwatch({required this.color, required this.onTap}); + + final Color? color; + final VoidCallback onTap; + + static const _rainbow = SweepGradient( + colors: [ + Color(0xFFFF6B6B), Color(0xFFFFC94D), Color(0xFF3FB58C), + Color(0xFF4DA6E0), Color(0xFFB07CF0), Color(0xFFFF6B6B), + ], + ); + + @override + Widget build(BuildContext context) { + final c = color; + return Semantics( + button: true, + label: '색상 직접 지정', + excludeSemantics: true, + child: GestureDetector( + onTap: onTap, + child: Container( + width: 22, + height: 22, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: c, + gradient: c == null ? _rainbow : null, + border: Border.all(color: c == null ? Colors.transparent : AppColors.surface, width: 2), + boxShadow: c == null ? null : [BoxShadow(color: c, spreadRadius: 2)], + ), + alignment: Alignment.center, + child: c == null ? const RupIcon(RupIconName.plus, size: 11, stroke: 3, color: Colors.white) : null, + ), + ), + ); + } +} diff --git a/flutter/lib/features/schedule/presentation/widgets/event_tile.dart b/flutter/lib/features/schedule/presentation/widgets/event_tile.dart new file mode 100644 index 0000000..1445f3a --- /dev/null +++ b/flutter/lib/features/schedule/presentation/widgets/event_tile.dart @@ -0,0 +1,211 @@ +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/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'; + +/// 하루 종일 일정의 시간 칸 표기 +const String kAllDayLabel = '하루 종일'; + +const double _kTimeColumn = 62; + +/// 완료 체크 상자 (시안 .rup-check) +class RupCheckBox extends StatelessWidget { + const RupCheckBox({super.key, required this.done, this.size = 24, this.iconSize = 14, this.stroke = 3}); + + final bool done; + final double size; + final double iconSize; + final double stroke; + + @override + Widget build(BuildContext context) { + return AnimatedContainer( + duration: AppDurations.fast, + width: size, + height: size, + decoration: BoxDecoration( + color: done ? AppColors.primary : Colors.transparent, + borderRadius: BorderRadius.circular(9), + border: Border.all(color: done ? AppColors.primary : AppColors.line, width: 2), + ), + alignment: Alignment.center, + child: done ? RupIcon(RupIconName.check, size: iconSize, stroke: stroke, color: Colors.white) : null, + ); + } +} + +/// "콩이, 보리 · 동물병원" — 대상 + 장소 +class EventMeta extends ConsumerWidget { + const EventMeta({super.key, required this.details, this.avatarSize = 15, this.gap = 5}); + + final EventDetails details; + final double avatarSize; + final double gap; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final names = ref.watch(targetNamesProvider); + final label = targetLabel(details.targetIds, names); + final place = details.place; + return Row( + children: [ + PetStack(targetIds: details.targetIds, size: avatarSize), + SizedBox(width: gap), + 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, + ), + ), + ), + ], + ); + } +} + +/// 분류 아이콘 상자 — 예약 일정에만 붙는다 +class CategoryBadge extends StatelessWidget { + const CategoryBadge({super.key, required this.details, this.size = 32, this.iconSize = 17, this.radius = 9}); + + final EventDetails details; + final double size; + final double iconSize; + final double radius; + + @override + Widget build(BuildContext context) { + final color = details.color; + return Container( + width: size, + height: size, + decoration: BoxDecoration( + color: color.withValues(alpha: .12), + borderRadius: BorderRadius.circular(radius), + ), + alignment: Alignment.center, + child: RupIcon(details.category.icon, size: iconSize, stroke: 2, color: color), + ); + } +} + +/// 일정 관리 목록의 일정 한 줄 (시안 .rup-event) +class EventTile extends StatelessWidget { + const EventTile({ + super.key, + required this.event, + required this.done, + required this.onTap, + required this.onToggleDone, + }); + + final ScheduleEvent event; + final bool done; + final VoidCallback onTap; + final VoidCallback onToggleDone; + + @override + Widget build(BuildContext context) { + final d = event.details; + const display = TextStyle(fontFamily: AppFonts.display, fontWeight: FontWeight.w800, color: AppColors.ink); + final start = d.start; + final end = d.end; + + return Opacity( + opacity: done ? .6 : 1, + child: Semantics( + container: true, + button: true, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: onTap, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 13), + decoration: BoxDecoration( + color: AppColors.surface, + borderRadius: BorderRadius.circular(AppRadius.card), + border: Border.all(color: AppColors.line2), + ), + child: IntrinsicHeight( + child: Row( + children: [ + Container( + width: 4, + decoration: BoxDecoration(color: d.color, borderRadius: BorderRadius.circular(4)), + ), + const SizedBox(width: 9), + SizedBox( + width: _kTimeColumn, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (start == null) + Text(kAllDayLabel, maxLines: 1, style: display.copyWith(fontSize: 14, height: 1.2)) + else ...[ + Text(start.label, maxLines: 1, style: display.copyWith(fontSize: 13.5, height: 1.25)), + if (end != null) + Text( + '~ ${end.label}', + maxLines: 1, + style: display.copyWith(fontSize: 12, height: 1.25, color: AppColors.muted), + ), + ], + ], + ), + ), + const SizedBox(width: 11), + const VerticalDivider(width: 1, thickness: 1, color: AppColors.line), + const SizedBox(width: AppSpacing.md), + if (d.kind == EventKind.reservation) ...[ + CategoryBadge(details: d), + const SizedBox(width: AppSpacing.md), + ], + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + d.title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleSmall!.copyWith( + fontWeight: FontWeight.w600, + height: AppTypography.normalHeight, + decoration: done ? TextDecoration.lineThrough : null, + decorationColor: AppColors.ink, + ), + ), + EventMeta(details: d), + ], + ), + ), + const SizedBox(width: AppSpacing.md), + Semantics( + button: true, + checked: done, + label: '${d.title} 완료', + excludeSemantics: true, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: onToggleDone, + child: Center(child: RupCheckBox(done: done, size: 26, iconSize: 15, stroke: 2.6)), + ), + ), + ], + ), + ), + ), + ), + ), + ); + } +} diff --git a/flutter/lib/features/schedule/presentation/widgets/schedule_calendar.dart b/flutter/lib/features/schedule/presentation/widgets/schedule_calendar.dart new file mode 100644 index 0000000..18ff982 --- /dev/null +++ b/flutter/lib/features/schedule/presentation/widgets/schedule_calendar.dart @@ -0,0 +1,623 @@ +import 'dart:ui' show lerpDouble; + +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/core/utils/date_format.dart'; +import 'package:tma_app/features/schedule/domain/schedule_book.dart'; +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'; + +/// 요일 머리 (일요일 시작) +const List kWeekdayHeads = ['일', '월', '화', '수', '목', '금', '토']; + +/// 주말 글자색 (시안 #E0716B / #5B9BD0) +const Color kSundayColor = Color(0xFFE0716B); +const Color kSaturdayColor = Color(0xFF5B9BD0); + +/// 달력 치수 — 칸 가로:세로 1:1.05, 줄·칸 사이 2 +const double _kCellAspect = 1.05; +const double _kGap = 2; +const int _kMaxDots = 9; +const double _kDot = 4; +const double _kDotsWidth = 18; + +/// 월 ↔ 주 접기 (.42s cubic-bezier(.4,0,.2,1)), 다른 주 흐리기 .3s +const Duration _kFold = Duration(milliseconds: 420); +const Cubic _kFoldCurve = Cubic(.4, 0, .2, 1); +const Duration _kFade = Duration(milliseconds: 300); + +/// 12개월 보기에서 고를 수 있는 해 +const int _kFirstYear = 2020; +const int _kLastYear = 2050; + +/// 12개월 보기에서 위아래로 이만큼 끌면 해가 바뀐다 +const double _kYearSwipe = 46; + +/// 일정 관리 달력 카드 — 월 보기(접으면 주 보기) · 12개월 보기 +class ScheduleCalendar extends StatefulWidget { + const ScheduleCalendar({ + super.key, + required this.book, + required this.selected, + required this.today, + required this.onSelect, + }); + + final ScheduleBook book; + final DateTime selected; + final DateTime today; + final ValueChanged onSelect; + + @override + State createState() => _ScheduleCalendarState(); +} + +class _ScheduleCalendarState extends State { + late int _year = widget.selected.year; + late int _month = widget.selected.month; + bool _compact = false; + bool _yearView = false; + double _swipe = 0; + + void _changeMonth(int delta) => setState(() { + final d = DateTime(_year, _month + delta); + _year = d.year; + _month = d.month; + }); + + void _changeWeek(int delta) { + final s = widget.selected; + final d = DateTime(s.year, s.month, s.day + delta * 7); + setState(() { + _year = d.year; + _month = d.month; + }); + widget.onSelect(d); + } + + void _changeYear(int delta) => setState(() => _year = (_year + delta).clamp(_kFirstYear, _kLastYear)); + + void _step(int delta) { + if (_yearView) { + _changeYear(delta); + } else if (_compact) { + _changeWeek(delta); + } else { + _changeMonth(delta); + } + } + + void _pickMonth(int month) { + setState(() { + _month = month; + _yearView = false; + }); + widget.onSelect(DateTime(_year, month)); + } + + @override + Widget build(BuildContext context) { + return RupCard( + padding: const EdgeInsets.all(18), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _Head( + title: _yearView ? '$_year년' : '$_year년 $_month월', + yearView: _yearView, + onTitle: () => setState(() => _yearView = !_yearView), + onPrev: () => _step(-1), + onNext: () => _step(1), + ), + const SizedBox(height: AppSpacing.md), + if (_yearView) + GestureDetector( + // 위아래로 끌어 해를 바꾼다 + onVerticalDragStart: (_) => _swipe = 0, + onVerticalDragUpdate: (d) { + _swipe += d.delta.dy; + if (_swipe.abs() > _kYearSwipe) { + _changeYear(_swipe < 0 ? 1 : -1); + _swipe = 0; + } + }, + child: _YearGrid( + year: _year, + currentMonth: _month, + today: widget.today, + book: widget.book, + onPick: _pickMonth, + ), + ) + else ...[ + const _WeekdayHeads(), + _MonthGrid( + year: _year, + month: _month, + compact: _compact, + book: widget.book, + selected: widget.selected, + today: widget.today, + onSelect: widget.onSelect, + ), + const SizedBox(height: AppSpacing.xs), + Center( + child: Semantics( + button: true, + label: _compact ? '달력 늘리기' : '달력 줄이기', + excludeSemantics: true, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => setState(() => _compact = !_compact), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 6), + child: RupIcon( + _compact ? RupIconName.chevD : RupIconName.chevU, + size: 22, + stroke: 2.4, + color: AppColors.muted, + ), + ), + ), + ), + ), + ], + ], + ), + ); + } +} + +class _Head extends StatelessWidget { + const _Head({ + required this.title, + required this.yearView, + required this.onTitle, + required this.onPrev, + required this.onNext, + }); + + final String title; + final bool yearView; + final VoidCallback onTitle; + final VoidCallback onPrev; + final VoidCallback onNext; + + @override + Widget build(BuildContext context) { + Widget navButton(RupIconName icon, String label, VoidCallback onTap) => Pressable( + onTap: onTap, + pressedScale: .9, + semanticLabel: label, + child: Container( + width: 34, + height: 34, + decoration: BoxDecoration( + color: AppColors.surface, + borderRadius: BorderRadius.circular(11), + border: Border.all(color: AppColors.line, width: 1.5), + ), + alignment: Alignment.center, + child: RupIcon(icon, size: 18, color: AppColors.ink2), + ), + ); + + return Row( + children: [ + Pressable( + onTap: onTitle, + pressedScale: 1, + semanticLabel: yearView ? '$title, 달 보기로' : '$title, 12개월 보기로', + child: Padding( + padding: const EdgeInsets.fromLTRB(0, 2, 4, 2), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + title, + style: const TextStyle( + fontFamily: AppFonts.display, + fontSize: 20, + fontWeight: FontWeight.w800, + height: AppTypography.displayNormalHeight, + color: AppColors.ink, + ), + ), + const SizedBox(width: 5), + RupIcon(yearView ? RupIconName.chevU : RupIconName.chevD, size: 17, stroke: 2.4, color: AppColors.muted), + ], + ), + ), + ), + const Spacer(), + navButton(RupIconName.chevL, '이전', onPrev), + const SizedBox(width: 6), + navButton(RupIconName.chevR, '다음', onNext), + ], + ); + } +} + +class _WeekdayHeads extends StatelessWidget { + const _WeekdayHeads(); + + @override + Widget build(BuildContext context) { + final base = Theme.of(context).textTheme.bodySmall!.copyWith( + fontSize: 11, + fontWeight: FontWeight.w600, + height: AppTypography.normalHeight, + ); + return Padding( + padding: const EdgeInsets.only(bottom: AppSpacing.sm), + child: Row( + children: [ + for (final (i, d) in kWeekdayHeads.indexed) ...[ + if (i > 0) const SizedBox(width: _kGap), + Expanded( + child: Text( + d, + textAlign: TextAlign.center, + style: base.copyWith( + color: i == 0 ? kSundayColor : (i == 6 ? kSaturdayColor : AppColors.muted), + ), + ), + ), + ], + ], + ), + ); + } +} + +/// 한 달의 날짜 칸 — [compact] 이면 고른 날짜가 있는 주만 남기고 위로 접는다 +class _MonthGrid extends StatelessWidget { + const _MonthGrid({ + required this.year, + required this.month, + required this.compact, + required this.book, + required this.selected, + required this.today, + required this.onSelect, + }); + + final int year; + final int month; + final bool compact; + final ScheduleBook book; + final DateTime selected; + final DateTime today; + final ValueChanged onSelect; + + @override + Widget build(BuildContext context) { + // 일요일 시작 — 1일 앞의 빈칸 수 + final lead = DateTime(year, month).weekday % 7; + final days = daysInMonth(year, month); + final cells = [ + for (var i = 0; i < lead; i++) null, + for (var d = 1; d <= days; d++) d, + ]; + while (cells.length % 7 != 0) { + cells.add(null); + } + final weeks = [for (var w = 0; w < cells.length ~/ 7; w++) cells.sublist(w * 7, w * 7 + 7)]; + final selWeek = selected.year == year && selected.month == month + ? weeks.indexWhere((w) => w.contains(selected.day)) + : 0; + + return LayoutBuilder( + builder: (context, constraints) { + final cellW = (constraints.maxWidth - _kGap * 6) / 7; + final rowH = cellW * _kCellAspect; + final fullH = rowH * weeks.length + _kGap * (weeks.length - 1); + return TweenAnimationBuilder( + tween: Tween(end: compact ? 1 : 0), + duration: _kFold, + curve: _kFoldCurve, + builder: (context, t, child) => ClipRect( + child: SizedBox( + height: lerpDouble(fullH, rowH, t), + child: OverflowBox( + alignment: Alignment.topCenter, + minHeight: fullH, + maxHeight: fullH, + child: Transform.translate( + offset: Offset(0, -selWeek * (rowH + _kGap) * t), + child: child, + ), + ), + ), + ), + child: Column( + children: [ + for (final (w, week) in weeks.indexed) ...[ + if (w > 0) const SizedBox(height: _kGap), + AnimatedOpacity( + duration: _kFade, + opacity: compact && w != selWeek ? 0 : 1, + child: SizedBox( + height: rowH, + child: Row( + children: [ + for (final (i, day) in week.indexed) ...[ + if (i > 0) const SizedBox(width: _kGap), + Expanded( + child: day == null + ? const SizedBox.shrink() + : _DayCell( + date: DateTime(year, month, day), + dow: i, + book: book, + selected: selected, + today: today, + onTap: onSelect, + ), + ), + ], + ], + ), + ), + ), + ], + ], + ), + ); + }, + ); + } +} + +class _DayCell extends StatelessWidget { + const _DayCell({ + required this.date, + required this.dow, + required this.book, + required this.selected, + required this.today, + required this.onTap, + }); + + final DateTime date; + + /// 0 = 일요일 + final int dow; + final ScheduleBook book; + final DateTime selected; + final DateTime today; + final ValueChanged onTap; + + @override + Widget build(BuildContext context) { + final isSel = date == selected; + final isToday = date == today; + final events = book.eventsOn(date); + final color = isSel + ? Colors.white + : dow == 0 + ? kSundayColor + : dow == 6 + ? kSaturdayColor + : isToday + ? AppColors.primary600 + : AppColors.ink; + + return Semantics( + button: true, + selected: isSel, + label: '${date.month}월 ${date.day}일${isToday ? ' 오늘' : ''}${events.isEmpty ? '' : ', 일정 ${events.length}건'}', + excludeSemantics: true, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => onTap(date), + child: AnimatedContainer( + duration: const Duration(milliseconds: 120), + padding: const EdgeInsets.only(top: 6), + decoration: BoxDecoration( + color: isSel ? AppColors.primary : Colors.transparent, + borderRadius: BorderRadius.circular(AppRadius.card), + ), + child: Column( + children: [ + Text( + '${date.day}', + style: Theme.of(context).textTheme.bodyMedium!.copyWith( + fontSize: 14, + fontWeight: FontWeight.w500, + height: AppTypography.normalHeight, + color: color, + ), + ), + const SizedBox(height: 3), + SizedBox( + width: _kDotsWidth, + child: Wrap( + spacing: 2.5, + runSpacing: 2.5, + children: [ + for (final e in events.take(_kMaxDots)) + Container( + width: _kDot, + height: _kDot, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: isSel ? Colors.white.withValues(alpha: .9) : e.details.color, + ), + ), + ], + ), + ), + ], + ), + ), + ), + ); + } +} + +/// 12개월 축소 보기 — 달을 누르면 그 달로 간다 +class _YearGrid extends StatelessWidget { + const _YearGrid({ + required this.year, + required this.currentMonth, + required this.today, + required this.book, + required this.onPick, + }); + + final int year; + final int currentMonth; + final DateTime today; + final ScheduleBook book; + final ValueChanged onPick; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(top: 2, bottom: AppSpacing.xs), + child: Column( + children: [ + for (var row = 0; row < 4; row++) ...[ + if (row > 0) const SizedBox(height: 10), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (var col = 0; col < 3; col++) ...[ + if (col > 0) const SizedBox(width: AppSpacing.sm), + Expanded( + child: _MiniMonth( + year: year, + month: row * 3 + col + 1, + current: row * 3 + col + 1 == currentMonth, + today: today, + book: book, + onTap: () => onPick(row * 3 + col + 1), + ), + ), + ], + ], + ), + ], + ], + ), + ); + } +} + +class _MiniMonth extends StatelessWidget { + const _MiniMonth({ + required this.year, + required this.month, + required this.current, + required this.today, + required this.book, + required this.onTap, + }); + + final int year; + final int month; + final bool current; + final DateTime today; + final ScheduleBook book; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final lead = DateTime(year, month).weekday % 7; + final days = daysInMonth(year, month); + final cells = [for (var i = 0; i < lead; i++) null, for (var d = 1; d <= days; d++) d]; + final ui = Theme.of(context).textTheme.bodySmall!; + + Widget cell(Widget child) => Expanded(child: Center(child: child)); + + final rows = [ + Row(children: [ + for (final d in kWeekdayHeads) + cell(Text(d, style: ui.copyWith(fontSize: 6.5, fontWeight: FontWeight.w700, color: AppColors.muted, height: 9 / 6.5))), + ]), + ]; + for (var i = 0; i < cells.length; i += 7) { + final week = cells.sublist(i, i + 7 > cells.length ? cells.length : i + 7); + rows.add(const SizedBox(height: 1)); + rows.add(Row(children: [ + for (var k = 0; k < 7; k++) + if (k < week.length && week[k] != null) + cell(_MiniDay(date: DateTime(year, month, week[k]!), today: today, book: book)) + else + const Expanded(child: SizedBox.shrink()), + ])); + } + + return Semantics( + button: true, + selected: current, + label: '$month월', + excludeSemantics: true, + child: GestureDetector( + onTap: onTap, + child: Container( + padding: const EdgeInsets.fromLTRB(4, 5, 4, 6), + decoration: BoxDecoration( + color: current ? AppColors.primary50 : Colors.transparent, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: current ? AppColors.primary100 : Colors.transparent, width: 1.5), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: const EdgeInsets.only(left: 2, bottom: AppSpacing.xs), + child: Text( + '$month월', + style: TextStyle( + fontFamily: AppFonts.display, + fontSize: 13, + fontWeight: FontWeight.w800, + height: AppTypography.displayNormalHeight, + color: current ? AppColors.primary600 : AppColors.ink, + ), + ), + ), + ...rows, + ], + ), + ), + ), + ); + } +} + +class _MiniDay extends StatelessWidget { + const _MiniDay({required this.date, required this.today, required this.book}); + + final DateTime date; + final DateTime today; + final ScheduleBook book; + + @override + Widget build(BuildContext context) { + final isToday = date == today; + final has = book.eventsOn(date).isNotEmpty; + return Container( + width: double.infinity, + decoration: BoxDecoration( + color: isToday ? AppColors.primary : Colors.transparent, + borderRadius: BorderRadius.circular(3), + ), + child: Text( + '${date.day}', + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodySmall!.copyWith( + fontSize: 7.5, + height: 11 / 7.5, + fontWeight: isToday || has ? FontWeight.w800 : FontWeight.w600, + color: isToday ? Colors.white : (has ? AppColors.primary600 : AppColors.ink2), + ), + ), + ); + } +} diff --git a/flutter/lib/features/schedule/presentation/widgets/schedule_view.dart b/flutter/lib/features/schedule/presentation/widgets/schedule_view.dart new file mode 100644 index 0000000..883a3ad --- /dev/null +++ b/flutter/lib/features/schedule/presentation/widgets/schedule_view.dart @@ -0,0 +1,140 @@ +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/core/utils/date_format.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/schedule_calendar.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/rup_toast.dart'; +import 'package:tma_app/shared/widgets/section_title.dart'; + +/// 일정 관리 — 함께 관리 · 달력 · 고른 날의 일정 +class ScheduleView extends ConsumerWidget { + const ScheduleView({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final book = ref.watch(scheduleBookProvider); + final selected = ref.watch(scheduleDateProvider); + final today = dateOnly(DateTime.now()); + final events = book.eventsOn(selected); + final textTheme = Theme.of(context).textTheme; + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const SizedBox(height: AppSpacing.xs), + const _ShareCard(), + const SizedBox(height: AppSpacing.md), + ScheduleCalendar( + book: book, + selected: selected, + today: today, + onSelect: ref.read(scheduleDateProvider.notifier).select, + ), + 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, + ), + ), + ), + RupSoftPillButton( + label: '일정 추가', + uiFont: true, + onPressed: () => unawaited(context.push(AppRoutes.eventCreate(selected))), + ), + ], + ), + ), + 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)), + ), + ], + ], + ); + } +} + +/// 함께 관리 — 가족·친구와 달력 공유 (서버 연동 전이라 안내만) +class _ShareCard extends ConsumerWidget { + const _ShareCard(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final textTheme = Theme.of(context).textTheme; + return RupCard( + onTap: () => ref.read(toastProvider.notifier).show('함께 관리는 곧 열릴 예정이에요', icon: RupIconName.users), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 15, vertical: 13), + child: Row( + children: [ + Container( + width: 36, + height: 36, + decoration: BoxDecoration(color: AppColors.primary50, borderRadius: BorderRadius.circular(11)), + alignment: Alignment.center, + child: const RupIcon(RupIconName.users, size: 19, stroke: 2.2, color: AppColors.primary600), + ), + const SizedBox(width: AppSpacing.md), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '함께 관리', + style: textTheme.titleSmall!.copyWith(fontSize: 14.5, height: AppTypography.normalHeight), + ), + const SizedBox(height: 1), + Text( + '가족·친구와 일정을 공유해보세요', + style: textTheme.bodySmall!.copyWith(color: AppColors.muted, height: AppTypography.normalHeight), + ), + ], + ), + ), + const RupIcon(RupIconName.chevR, size: 17, color: AppColors.muted), + ], + ), + ), + ); + } +} diff --git a/flutter/lib/features/schedule/presentation/widgets/target_avatar.dart b/flutter/lib/features/schedule/presentation/widgets/target_avatar.dart new file mode 100644 index 0000000..4109208 --- /dev/null +++ b/flutter/lib/features/schedule/presentation/widgets/target_avatar.dart @@ -0,0 +1,87 @@ +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/features/mypage/data/profile_repository.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/pet_avatar.dart'; +import 'package:tma_app/features/schedule/domain/schedule_event.dart'; +import 'package:tma_app/shared/widgets/person_avatar.dart'; + +/// 일정 대상 "나" 의 이름 +const String kSelfTargetName = '나'; + +/// 대상 이름을 몇 마리까지 적고 나머지는 수로 줄일지 (시안 evPetLabel) +const int _kLabelMaxNames = 3; + +/// 일정 대상 id → 이름 (지금 등록된 아이 + 나) +final targetNamesProvider = Provider>((ref) { + final pets = ref.watch(petListProvider).value ?? const []; + return {kSelfTargetId: kSelfTargetName, for (final p in pets) p.id: p.name}; +}); + +/// "콩이, 보리, 나 외 2명" — 지워진 아이는 건너뛴다 +String targetLabel(List ids, Map names) { + final known = [for (final id in ids) ?names[id]]; + if (known.length <= _kLabelMaxNames) return known.join(', '); + return '${known.take(_kLabelMaxNames).join(', ')} 외 ${known.length - _kLabelMaxNames}명'; +} + +/// 일정 대상 아바타 — 반려동물이면 아이 사진, "나" 면 보호자 사진 +class TargetAvatar extends ConsumerWidget { + const TargetAvatar({super.key, required this.targetId, required this.size}); + + final String targetId; + final double size; + + @override + Widget build(BuildContext context, WidgetRef ref) { + if (targetId == kSelfTargetId) { + return PersonAvatar( + size: size, + photoPath: ref.watch(profileProvider).photoPath, + background: AppColors.primary50, + ); + } + final pet = ref.watch(petByIdProvider(targetId)); + if (pet == null) return SizedBox.square(dimension: size); + return PetAvatar(pet: pet, size: size); + } +} + +/// 겹쳐 놓은 대상 아바타 (시안 PetStack) — 최대 [max] 개 +class PetStack extends StatelessWidget { + const PetStack({super.key, required this.targetIds, required this.size, this.max = 3}); + + final List targetIds; + final double size; + final int max; + + @override + Widget build(BuildContext context) { + final shown = targetIds.take(max).toList(); + final overlap = (size * .34).roundToDouble(); + return SizedBox( + height: size, + width: shown.isEmpty ? 0 : size + (shown.length - 1) * (size - overlap), + child: Stack( + clipBehavior: Clip.none, + children: [ + // 앞의 아바타가 위에 오도록 뒤에서부터 그린다 + for (var i = shown.length - 1; i >= 0; i--) + Positioned( + left: i * (size - overlap), + child: DecoratedBox( + decoration: const BoxDecoration( + shape: BoxShape.circle, + color: AppColors.surface, + boxShadow: [BoxShadow(color: AppColors.surface, spreadRadius: 1.5)], + ), + child: TargetAvatar(targetId: shown[i], size: size), + ), + ), + ], + ), + ); + } +} diff --git a/flutter/lib/features/shell/presentation/pages/tab_placeholder_page.dart b/flutter/lib/features/shell/presentation/pages/tab_placeholder_page.dart deleted file mode 100644 index f8fad5a..0000000 --- a/flutter/lib/features/shell/presentation/pages/tab_placeholder_page.dart +++ /dev/null @@ -1,39 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:tma_app/core/constants/app_colors.dart'; -import 'package:tma_app/core/constants/app_sizes.dart'; - -/// 아직 구현하지 않은 탭의 자리 — 사용자 흐름 순서대로 실제 화면으로 교체한다. -class TabPlaceholderPage extends StatelessWidget { - const TabPlaceholderPage({super.key, required this.title}); - - final String title; - - @override - Widget build(BuildContext context) { - final textTheme = Theme.of(context).textTheme; - return SafeArea( - child: Padding( - padding: const EdgeInsets.fromLTRB( - AppSpacing.xl, - AppSpacing.sm, - AppSpacing.xl, - 0, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(title, style: textTheme.headlineSmall), - const Spacer(), - Center( - child: Text( - '준비 중인 화면이에요', - style: textTheme.bodyMedium!.copyWith(color: AppColors.muted), - ), - ), - const Spacer(), - ], - ), - ), - ); - } -} diff --git a/flutter/test/schedule_domain_test.dart b/flutter/test/schedule_domain_test.dart new file mode 100644 index 0000000..6dd677d --- /dev/null +++ b/flutter/test/schedule_domain_test.dart @@ -0,0 +1,197 @@ +import 'dart:ui' show Color; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:tma_app/core/utils/hour_minute.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/domain/schedule_book.dart'; +import 'package:tma_app/features/schedule/domain/schedule_event.dart'; + +EventDetails _details( + String title, { + EventKind kind = EventKind.general, + EventCategory category = EventCategory.etc, + List targets = const ['pet-1'], + HourMinute? start, +}) => + EventDetails(title: title, kind: kind, category: category, targetIds: targets, start: start); + +ScheduleEvent _event(String id, DateTime date, EventDetails d) => ScheduleEvent(id: id, date: date, details: d); + +void main() { + group('HourMinute', () { + test('12시간제로 보여 준다', () { + expect(const HourMinute(0, 5).label, '오전 12:05'); + expect(const HourMinute(9, 0).label, '오전 9:00'); + expect(const HourMinute(12, 30).label, '오후 12:30'); + expect(const HourMinute(23, 59).label, '오후 11:59'); + }); + + test('저장 형식을 읽고, 틀린 값은 null', () { + expect(HourMinute.tryParse('07:45'), const HourMinute(7, 45)); + expect(HourMinute.tryParse('하루 종일'), isNull); + expect(HourMinute.tryParse('24:00'), isNull); + }); + + test('하루를 넘는 분은 다음 날로 돌린다', () { + expect(HourMinute.fromMinutes(24 * 60 + 1), const HourMinute(0, 1)); + }); + }); + + group('RepeatSpec', () { + final from = DateTime(2026, 9, 28); // 월요일 + + test('시작일 전에는 반복하지 않는다', () { + expect(const RepeatSpec(RepeatPattern.daily).matches(from, DateTime(2026, 9, 27)), isFalse); + }); + + test('평일만 / 주말만', () { + const weekday = RepeatSpec(RepeatPattern.weekday); + const weekend = RepeatSpec(RepeatPattern.weekend); + expect(weekday.matches(from, DateTime(2026, 10, 2)), isTrue); // 금 + expect(weekday.matches(from, DateTime(2026, 10, 3)), isFalse); // 토 + expect(weekend.matches(from, DateTime(2026, 10, 4)), isTrue); // 일 + expect(weekend.matches(from, DateTime(2026, 10, 5)), isFalse); // 월 + }); + + test('격주는 같은 요일 2주마다', () { + const biweekly = RepeatSpec(RepeatPattern.every, n: 2); + expect(biweekly.matches(from, DateTime(2026, 10, 5)), isFalse); + expect(biweekly.matches(from, DateTime(2026, 10, 12)), isTrue); + }); + + test('3일마다', () { + const every3 = RepeatSpec(RepeatPattern.every, unit: RepeatUnit.day, n: 3); + expect(every3.matches(from, DateTime(2026, 10, 1)), isTrue); + expect(every3.matches(from, DateTime(2026, 10, 2)), isFalse); + }); + + test('매월은 같은 날짜 — 그 날이 없는 달은 건너뛴다', () { + final jan31 = DateTime(2026, 1, 31); + const monthly = RepeatSpec(RepeatPattern.every, unit: RepeatUnit.month); + expect(monthly.matches(jan31, DateTime(2026, 2, 28)), isFalse); + expect(monthly.matches(jan31, DateTime(2026, 3, 31)), isTrue); + }); + + test('사람이 읽는 문구', () { + expect(const RepeatSpec(RepeatPattern.weekday).label, '평일마다'); + expect(const RepeatSpec(RepeatPattern.every, unit: RepeatUnit.month, n: 2).label, '2개월 마다'); + }); + }); + + group('ScheduleBook', () { + final day = DateTime(2026, 9, 28); + + test('하루 일정은 시간순, 하루 종일은 뒤에 — 반복 일정도 함께', () { + final book = ScheduleBook( + events: [ + _event('a', day, _details('하루 종일 일정')), + _event('b', day, _details('저녁', start: const HourMinute(19, 0))), + ], + rules: [ + RepeatRule(id: 'r1', spec: const RepeatSpec(RepeatPattern.daily), from: day, details: _details('아침 산책', start: const HourMinute(7, 0))), + ], + ); + expect(book.eventsOn(day).map((e) => e.details.title), ['아침 산책', '저녁', '하루 종일 일정']); + expect(book.eventsOn(day).first.id, 'r1@2026-09-28'); + }); + + test('반복 일정 id 로 그 날짜의 일정을 찾는다', () { + final book = ScheduleBook(rules: [ + RepeatRule(id: 'r1', spec: const RepeatSpec(RepeatPattern.daily), from: day, details: _details('산책')), + ]); + final found = book.find('r1@2026-10-01'); + expect(found?.date, DateTime(2026, 10, 1)); + expect(found?.isRepeat, isTrue); + }); + + test('일반 일정을 반복으로 바꾸면 그 날부터 규칙이 된다', () { + final target = _event('a', day, _details('산책')); + final book = ScheduleBook(events: [target]).update( + target, + _details('산책'), + spec: const RepeatSpec(RepeatPattern.daily), + newEventId: () => 'e-new', + newRuleId: () => 'r-new', + ); + expect(book.events, isEmpty); + expect(book.rules.single.from, day); + expect(book.eventsOn(DateTime(2026, 9, 30)).single.details.title, '산책'); + }); + + test('반복을 끄면 보고 있던 날짜의 일정 하나만 남고 그날 완료 표시는 지운다', () { + final rule = RepeatRule(id: 'r1', spec: const RepeatSpec(RepeatPattern.daily), from: day, details: _details('산책')); + final instance = rule.instanceOn(DateTime(2026, 10, 1)); + final book = ScheduleBook(rules: [rule], doneIds: {instance.id}).update( + instance, + _details('산책'), + newEventId: () => 'e-new', + newRuleId: () => 'r-new', + ); + expect(book.rules, isEmpty); + expect(book.events.single.date, DateTime(2026, 10, 1)); + expect(book.doneIds, isEmpty); + }); + + test('반복 일정을 지우면 규칙 전체가 지워진다', () { + final rule = RepeatRule(id: 'r1', spec: const RepeatSpec(RepeatPattern.daily), from: day, details: _details('산책')); + final book = ScheduleBook(rules: [rule]).delete(rule.instanceOn(day)); + expect(book.eventsOn(DateTime(2026, 10, 5)), isEmpty); + }); + + test('완료 체크는 켜고 끈다', () { + final book = const ScheduleBook().toggleDone('a'); + expect(book.isDone('a'), isTrue); + expect(book.toggleDone('a').isDone('a'), isFalse); + }); + + test('아이를 지우면 그 아이만 떼고, 대상이 없는 일정은 지운다', () { + final book = ScheduleBook(events: [ + _event('both', day, _details('산책', targets: ['pet-1', 'pet-2'])), + _event('only', day, _details('미용', targets: ['pet-1'])), + ]).dropTarget('pet-1'); + expect(book.events.single.id, 'both'); + expect(book.events.single.details.targetIds, ['pet-2']); + }); + + test('예약 일정은 오늘 이후 가까운 순 4개까지, 일반 일정은 빠진다', () { + final res = _details('병원', kind: EventKind.reservation, category: EventCategory.hospital); + final book = ScheduleBook(events: [ + _event('past', DateTime(2026, 9, 1), res), + for (var i = 5; i >= 1; i--) _event('r$i', DateTime(2026, 10, i), res), + _event('care', DateTime(2026, 9, 29), _details('산책')), + ]); + expect(book.upcomingReservations(day).map((e) => e.id), ['r1', 'r2', 'r3', 'r4']); + }); + + test('오늘 할 일은 오늘의 일반 일정만', () { + final book = ScheduleBook(events: [ + _event('care', day, _details('산책')), + _event('res', day, _details('병원', kind: EventKind.reservation)), + ]); + expect(book.todayTasks(day).single.id, 'care'); + }); + }); + + test('일정은 저장 형식으로 바꿨다가 그대로 되살린다', () { + final e = ScheduleEvent( + id: 'e1', + date: DateTime(2026, 9, 28), + details: const EventDetails( + title: '병원', + kind: EventKind.reservation, + category: EventCategory.hospital, + targetIds: ['pet-1', kSelfTargetId], + start: HourMinute(14, 0), + end: HourMinute(15, 30), + place: '동물병원', + labelColor: Color(0xFF3FB58C), + ), + ); + final back = ScheduleEvent.fromJson(e.toJson()); + expect(back.details.targetIds, ['pet-1', kSelfTargetId]); + expect(back.details.end, const HourMinute(15, 30)); + expect(back.details.color, const Color(0xFF3FB58C)); + expect(back.details.kind, EventKind.reservation); + }); +} diff --git a/flutter/test/schedule_flow_test.dart b/flutter/test/schedule_flow_test.dart new file mode 100644 index 0000000..174ac2e --- /dev/null +++ b/flutter/test/schedule_flow_test.dart @@ -0,0 +1,141 @@ +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +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'; +import 'package:tma_app/features/schedule/domain/schedule_event.dart'; +import 'package:tma_app/features/schedule/presentation/pages/event_editor_page.dart'; + +import 'support/app_harness.dart'; + +DateTime get _today => dateOnly(DateTime.now()); + +/// 기기에 미리 넣어 둘 일정 +String _events(List events) => jsonEncode([for (final e in events) e.toJson()]); + +Map _withPet({List events = const []}) => { + 'pets.v1': jsonEncode([samplePet().toJson()]), + if (events.isNotEmpty) 'schedule.events.v1': _events(events), + }; + +Future _openRecords(WidgetTester tester) async { + await tester.tap(find.bySemanticsLabel('일정·기록')); + await settle(tester); +} + +/// 홈 화면의 세로 목록까지 내려 [finder] 를 보이게 한다 (가로로 넘기는 아이 목록과 구분) +Future _scrollHomeTo(WidgetTester tester, Finder finder) => + tester.scrollUntilVisible(finder, 200, scrollable: find.byType(Scrollable).first); + +Future _tapVisible(WidgetTester tester, Finder finder) async { + await tester.ensureVisible(finder); + await tester.pump(); + await tester.tap(finder); + await settle(tester); +} + +void main() { + testWidgets('일정 관리에서 일정을 추가하면 목록과 홈 오늘 할 일에 나타나고, 홈에서 완료 체크한다', (tester) async { + final prefs = await launchSignedIn(tester, extra: _withPet()); + await _openRecords(tester); + expect(find.textContaining('오늘 일정', findRichText: true), findsOneWidget); + expect(find.text('이 날은 일정이 없어요.'), findsOneWidget); + + await tester.tap(find.text('일정 추가')); + await settle(tester); + expect(find.byType(EventEditorPage), findsOneWidget); + + // 이름 없이 저장하면 안내만 하고 머문다 + await _tapVisible(tester, find.text('일정 저장')); + // 입력칸 안내 문구 + 토스트 + expect(find.text('일정 이름을 입력해주세요'), findsNWidgets(2)); + expect(find.byType(EventEditorPage), findsOneWidget); + + await tester.enterText(find.byType(TextField).first, '저녁 산책'); + await _tapVisible(tester, find.text('일정 저장')); + expect(find.byType(EventEditorPage), findsNothing); + expect(find.text('저녁 산책'), findsOneWidget); + expect(prefs.getString('schedule.events.v1'), contains('저녁 산책')); + + await tester.tap(find.bySemanticsLabel('홈')); + await settle(tester); + await _scrollHomeTo(tester, find.text('0/1 완료')); + // 다음 일정 카드 + 오늘 할 일 목록 + expect(find.text('저녁 산책'), findsNWidgets(2)); + + await tester.tap(find.text('저녁 산책').last); + await settle(tester); + expect(find.text('1/1 완료'), findsOneWidget); + expect(find.text('오늘의 일정을 전부 끝냈어요! 고생하셨습니다 🧡'), findsOneWidget); + }); + + testWidgets('일반 일정 칩으로 이름을 채우고 반복을 켜면 끝없는 반복 일정으로 저장된다', (tester) async { + final prefs = await launchSignedIn(tester, extra: _withPet()); + await _openRecords(tester); + await tester.tap(find.text('일정 추가')); + await settle(tester); + + await tester.tap(find.text('목욕')); + await tester.pump(); + expect(find.widgetWithText(TextField, '목욕'), findsOneWidget); + + await _tapVisible(tester, find.bySemanticsLabel('반복 일정')); + expect(find.text('매일 반복'), findsOneWidget); + await _tapVisible(tester, find.text('격주')); + expect(find.text('2주 마다 반복'), findsOneWidget); + + await _tapVisible(tester, find.text('일정 저장')); + expect(find.text('여러 날짜에 일정을 추가했어요'), findsOneWidget); + expect(prefs.getString('schedule.rules.v1'), contains('목욕')); + expect(find.text('목욕'), findsOneWidget); + }); + + testWidgets('일정을 누르면 수정 화면이 열리고, 제거하면 목록에서 사라진다', (tester) async { + final walk = ScheduleEvent( + id: 'e1', + date: _today, + details: const EventDetails( + title: '미용 예약', + kind: EventKind.reservation, + category: EventCategory.grooming, + targetIds: ['pet-1'], + start: HourMinute(14, 0), + ), + ); + await launchSignedIn(tester, extra: _withPet(events: [walk])); + await _openRecords(tester); + expect(find.text('오후 2:00'), findsOneWidget); + + await tester.tap(find.text('미용 예약')); + await settle(tester); + expect(find.text('일정 수정'), findsOneWidget); + + await _tapVisible(tester, find.text('일정 제거')); + expect(find.text('일정을 제거할까요?'), findsOneWidget); + await tester.tap(find.text('제거하기')); + await settle(tester); + + expect(find.byType(EventEditorPage), findsNothing); + expect(find.text('미용 예약'), findsNothing); + expect(find.text('이 날은 일정이 없어요.'), findsOneWidget); + }); + + testWidgets('다가오는 예약 일정은 홈에 D-day 로 보인다', (tester) async { + final visit = ScheduleEvent( + id: 'e1', + date: DateTime(_today.year, _today.month, _today.day + 3), + details: const EventDetails( + title: '예방접종', + kind: EventKind.reservation, + category: EventCategory.vaccine, + targetIds: ['pet-1'], + ), + ); + await launchSignedIn(tester, extra: _withPet(events: [visit])); + await _scrollHomeTo(tester, find.text('D-3')); + expect(find.text('예방접종'), findsOneWidget); + expect(find.text('예약된 일정이 없어요'), findsNothing); + }); +}