fix(flutter): 홈·일정 시안 점검 반영과 우리 아이 순서 바꾸기

홈
- 우리 아이를 꾹 눌러(320ms) 끌면 순서가 바뀌고 저장된다 (PetRepository.reorder).
  보관함으로 간 아이는 목록에서 빠지므로 원래 순서대로 뒤에 둔다.
- 오늘 할 일 진행 막대 끝을 둥글게, 아이 사진 겹침의 글자 아래 여백(inline-flex)을 반영하고
  긴 일정 제목은 줄을 바꾼다.
- 최근 다이어리 칸을 누르면 시안처럼 일정·기록 탭의 다이어리 목록으로 간다.

일정
- 하루 종일이 켜진 채 기간을 하루 2회로 정하면 회차 시각(08:00·20:00)이 저장되지 않던 문제를 고쳤다.
- 예약 분류를 고른 뒤 같은 일반 일정 칩을 다시 누르면 분류 선택이 풀린다.
- 주기 직접 설정은 2주마다로 시작하고, 매일·평일을 골랐다 돌아와도 단위·횟수가 남는다.
- 12개월 보기에서는 일정 목록을 숨기고, 달력 점 묶음을 칸 가운데에 둔다.
- 저장 버튼 흐림 .45, 일정 제거는 전용 확인창으로.

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