generated from ttipo/project-base
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:
@@ -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)),
|
||||
),
|
||||
],
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user