generated from ttipo/project-base
feat(flutter): 일정·기록 탭의 일정 관리와 홈 일정 구현
마이페이지 다음 흐름 — 홈 "일정" 과 일정·기록 탭이 실제 일정을 다룬다. - 달력: 일정 색 점, 월↔주 접기, 12개월 보기 / 고른 날의 일정 목록과 완료 체크 - 일정 추가·수정: 대상(여러 마리·나), 예약/일반 일정, 시간, 장소, 라벨 색 - 반복(매일·평일·주말·N일/주/개월마다) — 끝나는 날 없는 반복은 날짜를 만들지 않고 규칙으로 보관 - 기간 설정 + 하루 횟수 — 기간 안의 날짜(반복 주기에 맞는 날)마다 만든다 - 홈: 다음 일정(도는 테두리), 오늘 할 일(4개까지·밤 11시 하루 정리), 예약 일정 D-day - 아이를 지우면 일정에서 그 아이만 떼고, 회원탈퇴는 일정도 지운다 - 일정은 기기에 저장한다 (백엔드 API 가 생기면 ScheduleRepository 구현을 바꾼다) 시안과 다른 점: "함께 관리"는 서버가 필요해 안내만, 다이어리 탭은 다음 단계, 기간+반복에 맞는 날이 없으면 저장하지 않고 알린다 (시안은 빈 저장 후 "추가했어요"). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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<GoRouter>((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<GoRouter>((ref) {
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: AppRoutes.records,
|
||||
builder: (context, state) =>
|
||||
const TabPlaceholderPage(title: '일정·기록'),
|
||||
builder: (context, state) => const RecordsPage(),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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<double> 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<double>(
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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<RecordsTab>(
|
||||
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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<RecordsTab> {
|
||||
@override
|
||||
RecordsTab build() => RecordsTab.schedule;
|
||||
|
||||
void select(RecordsTab tab) => state = tab;
|
||||
}
|
||||
|
||||
final recordsTabProvider = NotifierProvider<RecordsTabNotifier, RecordsTab>(RecordsTabNotifier.new);
|
||||
@@ -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<ScheduleBook> fetch();
|
||||
|
||||
Future<void> saveEvents(List<ScheduleEvent> events);
|
||||
|
||||
Future<void> saveRules(List<RepeatRule> rules);
|
||||
|
||||
Future<void> saveDone(Set<String> 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<Map<String, Object?>> _decodeList(String? raw) =>
|
||||
raw == null ? const [] : (jsonDecode(raw) as List<Object?>).whereType<Map<String, Object?>>().toList();
|
||||
|
||||
/// 임시 구현 — 기기(SharedPreferences)에 JSON 으로 저장한다.
|
||||
class LocalScheduleRepository implements ScheduleRepository {
|
||||
LocalScheduleRepository(this._prefs);
|
||||
|
||||
final SharedPreferences _prefs;
|
||||
|
||||
@override
|
||||
Future<ScheduleBook> 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<void> saveEvents(List<ScheduleEvent> events) =>
|
||||
_prefs.setString(_kEventsKey, jsonEncode([for (final e in events) e.toJson()]));
|
||||
|
||||
@override
|
||||
Future<void> saveRules(List<RepeatRule> rules) =>
|
||||
_prefs.setString(_kRulesKey, jsonEncode([for (final r in rules) r.toJson()]));
|
||||
|
||||
@override
|
||||
Future<void> saveDone(Set<String> doneIds) => _prefs.setStringList(_kDoneKey, doneIds.toList());
|
||||
}
|
||||
|
||||
final scheduleRepositoryProvider = Provider<ScheduleRepository>((ref) {
|
||||
return LocalScheduleRepository(ref.watch(sharedPreferencesProvider));
|
||||
});
|
||||
|
||||
/// 자주 쓰는 일반 일정 목록 — 지우거나 더한 목록이 다음 일정 추가에도 남는다
|
||||
class CarePresetRepository {
|
||||
CarePresetRepository(this._prefs);
|
||||
|
||||
final SharedPreferences _prefs;
|
||||
|
||||
List<CarePreset> load() {
|
||||
final raw = _prefs.getString(_kCaresKey);
|
||||
if (raw == null) return kDefaultCarePresets;
|
||||
return _decodeList(raw).map(CarePreset.fromJson).toList();
|
||||
}
|
||||
|
||||
Future<void> save(List<CarePreset> presets) =>
|
||||
_prefs.setString(_kCaresKey, jsonEncode([for (final p in presets) p.toJson()]));
|
||||
}
|
||||
|
||||
final carePresetRepositoryProvider = Provider<CarePresetRepository>((ref) {
|
||||
return CarePresetRepository(ref.watch(sharedPreferencesProvider));
|
||||
});
|
||||
@@ -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<String, Object?> toJson() => {'title': title, 'category': category.name};
|
||||
|
||||
factory CarePreset.fromJson(Map<String, Object?> json) =>
|
||||
CarePreset(json['title']! as String, EventCategory.byName(json['category'] as String?));
|
||||
}
|
||||
|
||||
/// 기본 목록 (시안 CARE_PRESETS) — 사용자가 지우거나 더한 목록은 기기에 남는다
|
||||
const List<CarePreset> 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;
|
||||
@@ -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<EventCategory> 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<Color> kLabelColors = [
|
||||
Color(0xFFFF6B6B), Color(0xFFF2974E), Color(0xFFFFC94D), Color(0xFF3FB58C),
|
||||
Color(0xFF4DA6E0), Color(0xFFB07CF0), Color(0xFFE86BA8), Color(0xFF9AA0A6),
|
||||
];
|
||||
@@ -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<String, Object?> toJson() => {'mode': mode.name, 'unit': unit.name, 'n': n};
|
||||
|
||||
factory RepeatSpec.fromJson(Map<String, Object?> 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<String, Object?> toJson() => {
|
||||
'id': id,
|
||||
'from': toIsoDate(from),
|
||||
'spec': spec.toJson(),
|
||||
'details': details.toJson(),
|
||||
};
|
||||
|
||||
factory RepeatRule.fromJson(Map<String, Object?> json) => RepeatRule(
|
||||
id: json['id']! as String,
|
||||
from: parseIsoDate(json['from']! as String),
|
||||
spec: RepeatSpec.fromJson(json['spec']! as Map<String, Object?>),
|
||||
details: EventDetails.fromJson(json['details']! as Map<String, Object?>),
|
||||
);
|
||||
}
|
||||
@@ -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<ScheduleEvent> events;
|
||||
final List<RepeatRule> rules;
|
||||
|
||||
/// 완료한 일정 id — 반복 일정은 날짜별 id("규칙@날짜")로 따로 체크한다
|
||||
final Set<String> doneIds;
|
||||
|
||||
bool isDone(String id) => doneIds.contains(id);
|
||||
|
||||
// ── 조회 ────────────────────────────────────────────────
|
||||
/// [date] 의 일정 — 시간순, 하루 종일은 뒤로
|
||||
List<ScheduleEvent> 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<ScheduleEvent> todayTasks(DateTime today) =>
|
||||
eventsOn(today).where((e) => e.details.kind == EventKind.general).toList();
|
||||
|
||||
/// 다가오는 예약 일정 — 오늘부터 [kUpcomingLookaheadDays]일 안, 가까운 순 [kUpcomingLimit]개
|
||||
List<ScheduleEvent> upcomingReservations(DateTime today) {
|
||||
final start = dateOnly(today);
|
||||
final end = start.add(const Duration(days: kUpcomingLookaheadDays));
|
||||
final list = <ScheduleEvent>[
|
||||
...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<ScheduleEvent>? events, List<RepeatRule>? rules, Set<String>? doneIds}) =>
|
||||
ScheduleBook(
|
||||
events: events ?? this.events,
|
||||
rules: rules ?? this.rules,
|
||||
doneIds: doneIds ?? this.doneIds,
|
||||
);
|
||||
|
||||
ScheduleBook addEvents(List<ScheduleEvent> 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<ScheduleEvent> 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 = <ScheduleEvent>[];
|
||||
for (final e in events) {
|
||||
final d = strip(e.details);
|
||||
if (d != null) keptEvents.add(e.copyWith(details: d));
|
||||
}
|
||||
final keptRules = <RepeatRule>[];
|
||||
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<String> _withoutRule(String ruleId) => doneIds.where((id) => !id.startsWith('$ruleId@')).toSet();
|
||||
}
|
||||
@@ -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<String> 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<String>? targetIds}) => EventDetails(
|
||||
title: title,
|
||||
kind: kind,
|
||||
category: category,
|
||||
targetIds: targetIds ?? this.targetIds,
|
||||
start: start,
|
||||
end: end,
|
||||
place: place,
|
||||
labelColor: labelColor,
|
||||
);
|
||||
|
||||
Map<String, Object?> 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<String, Object?> 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<String>().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<String, Object?> toJson() => {'id': id, 'date': toIsoDate(date), ...details.toJson()};
|
||||
|
||||
factory ScheduleEvent.fromJson(Map<String, Object?> 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);
|
||||
}
|
||||
@@ -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<int, List<HourMinute>> _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<Pet> 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<String> _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<HourMinute> _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<void> _removeCare(CarePreset p) async {
|
||||
await ref.read(carePresetsProvider.notifier).remove(p.title);
|
||||
if (_title.text == p.title) _title.clear();
|
||||
}
|
||||
|
||||
Future<void> _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<void> _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<void> _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<void> _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<void> _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<void> _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<EventDraft> _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 : <HourMinute?>[null])
|
||||
(date: d, details: _details(startOverride: t, dropEnd: multiDose)),
|
||||
];
|
||||
}
|
||||
|
||||
Future<void> _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<void> _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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<ScheduleBook> {
|
||||
ScheduleRepository get _repo => ref.read(scheduleRepositoryProvider);
|
||||
int _serial = 0;
|
||||
|
||||
@override
|
||||
Future<ScheduleBook> build() => ref.watch(scheduleRepositoryProvider).fetch();
|
||||
|
||||
ScheduleBook get _book => state.value ?? ScheduleBook.empty;
|
||||
|
||||
/// 같은 순간에 여러 건을 만들어도 겹치지 않는 id
|
||||
String _newId(String prefix) => '$prefix${DateTime.now().microsecondsSinceEpoch}_${_serial++}';
|
||||
|
||||
List<ScheduleEvent> _materialize(List<EventDraft> drafts) => [
|
||||
for (final d in drafts) ScheduleEvent(id: _newId('e'), date: dateOnly(d.date), details: d.details),
|
||||
];
|
||||
|
||||
Future<void> _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<void> addEvents(List<EventDraft> drafts) => _commit(_book.addEvents(_materialize(drafts)));
|
||||
|
||||
/// 끝나는 날 없는 반복 일정
|
||||
Future<void> addRule(DateTime from, RepeatSpec spec, EventDetails details) => _commit(
|
||||
_book.addRule(RepeatRule(id: _newId('r'), spec: spec, from: dateOnly(from), details: details)),
|
||||
);
|
||||
|
||||
Future<void> edit(ScheduleEvent target, EventDetails details, {RepeatSpec? spec}) => _commit(
|
||||
_book.update(
|
||||
target,
|
||||
details,
|
||||
spec: spec,
|
||||
newEventId: () => _newId('e'),
|
||||
newRuleId: () => _newId('r'),
|
||||
),
|
||||
);
|
||||
|
||||
/// 기간 설정으로 바꾼 수정 — 원래 일정(반복이면 규칙 전체)을 새 목록으로 대신한다
|
||||
Future<void> replace(ScheduleEvent target, List<EventDraft> drafts) =>
|
||||
_commit(_book.replace(target, _materialize(drafts)));
|
||||
|
||||
Future<void> delete(ScheduleEvent target) => _commit(_book.delete(target));
|
||||
|
||||
Future<void> toggleDone(String id) => _commit(_book.toggleDone(id));
|
||||
|
||||
/// 아이를 지우면 일정에서 그 아이만 뗀다
|
||||
Future<void> dropTarget(String targetId) => _commit(_book.dropTarget(targetId));
|
||||
}
|
||||
|
||||
final scheduleProvider = AsyncNotifierProvider<ScheduleNotifier, ScheduleBook>(ScheduleNotifier.new);
|
||||
|
||||
/// 불러오기 전에도 쓸 수 있는 일정 (빈 목록으로 시작)
|
||||
final scheduleBookProvider = Provider<ScheduleBook>((ref) {
|
||||
return ref.watch(scheduleProvider).value ?? ScheduleBook.empty;
|
||||
});
|
||||
|
||||
/// 일정 관리 달력에서 보고 있는 날짜 — 일정을 추가하고 돌아와도 유지한다
|
||||
class ScheduleDateNotifier extends Notifier<DateTime> {
|
||||
@override
|
||||
DateTime build() => dateOnly(DateTime.now());
|
||||
|
||||
void select(DateTime date) => state = dateOnly(date);
|
||||
}
|
||||
|
||||
final scheduleDateProvider = NotifierProvider<ScheduleDateNotifier, DateTime>(ScheduleDateNotifier.new);
|
||||
|
||||
/// 자주 쓰는 일반 일정 목록
|
||||
class CarePresetNotifier extends Notifier<List<CarePreset>> {
|
||||
@override
|
||||
List<CarePreset> build() => ref.watch(carePresetRepositoryProvider).load();
|
||||
|
||||
Future<void> _save(List<CarePreset> next) async {
|
||||
state = next;
|
||||
await ref.read(carePresetRepositoryProvider).save(next);
|
||||
}
|
||||
|
||||
/// 같은 이름이 이미 있으면 그대로 둔다
|
||||
Future<void> add(CarePreset preset) async {
|
||||
if (state.any((p) => p.title == preset.title)) return;
|
||||
await _save([...state, preset]);
|
||||
}
|
||||
|
||||
Future<void> remove(String title) => _save(state.where((p) => p.title != title).toList());
|
||||
}
|
||||
|
||||
final carePresetsProvider = NotifierProvider<CarePresetNotifier, List<CarePreset>>(CarePresetNotifier.new);
|
||||
@@ -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<EventCategory> 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<bool> 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<RepeatSpec> onSpec;
|
||||
final ValueChanged<bool> 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<RepeatSpec> 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<Color> 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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<String> 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<DateTime> onSelect;
|
||||
|
||||
@override
|
||||
State<ScheduleCalendar> createState() => _ScheduleCalendarState();
|
||||
}
|
||||
|
||||
class _ScheduleCalendarState extends State<ScheduleCalendar> {
|
||||
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<DateTime> onSelect;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// 일요일 시작 — 1일 앞의 빈칸 수
|
||||
final lead = DateTime(year, month).weekday % 7;
|
||||
final days = daysInMonth(year, month);
|
||||
final cells = <int?>[
|
||||
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<double>(
|
||||
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<DateTime> 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<int> 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 = <int?>[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 = <Widget>[
|
||||
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),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<Map<String, String>>((ref) {
|
||||
final pets = ref.watch(petListProvider).value ?? const <Pet>[];
|
||||
return {kSelfTargetId: kSelfTargetName, for (final p in pets) p.id: p.name};
|
||||
});
|
||||
|
||||
/// "콩이, 보리, 나 외 2명" — 지워진 아이는 건너뛴다
|
||||
String targetLabel(List<String> ids, Map<String, String> 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<String> 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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<String> 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);
|
||||
});
|
||||
}
|
||||
@@ -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<ScheduleEvent> events) => jsonEncode([for (final e in events) e.toJson()]);
|
||||
|
||||
Map<String, Object> _withPet({List<ScheduleEvent> events = const []}) => {
|
||||
'pets.v1': jsonEncode([samplePet().toJson()]),
|
||||
if (events.isNotEmpty) 'schedule.events.v1': _events(events),
|
||||
};
|
||||
|
||||
Future<void> _openRecords(WidgetTester tester) async {
|
||||
await tester.tap(find.bySemanticsLabel('일정·기록'));
|
||||
await settle(tester);
|
||||
}
|
||||
|
||||
/// 홈 화면의 세로 목록까지 내려 [finder] 를 보이게 한다 (가로로 넘기는 아이 목록과 구분)
|
||||
Future<void> _scrollHomeTo(WidgetTester tester, Finder finder) =>
|
||||
tester.scrollUntilVisible(finder, 200, scrollable: find.byType(Scrollable).first);
|
||||
|
||||
Future<void> _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);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user