generated from ttipo/project-base
feat(flutter): 알림 화면과 홈 종 새 알림 표시 구현
시안의 알림 화면(오늘·어제·최근 7일 묶음)과 홈 종 버튼의 새 알림 점을 옮겼다. 서버가 아직 없어 공유 다이어리의 새 글·댓글 알림은 구조만 두고, 기기에서 만들 수 있는 기록 알림(저녁 8시까지 오늘 기록이 없는 다이어리)부터 보여 준다. 다이어리별 기록 알림 스위치와 마이페이지 일정·다이어리 알림 설정을 따른다. 시각에 따라 달라지는 동작을 테스트할 수 있게 clockProvider 로 현재 시각을 주입하고, 앱을 켜 둔 채 8시·자정이 지나도 알림이 맞게 바뀌도록 그 시각에 다시 계산한다. 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/mypage/presentation/pages/my_page.dart';
|
||||
import 'package:tma_app/features/mypage/presentation/pages/my_pets_page.dart';
|
||||
import 'package:tma_app/features/mypage/presentation/pages/policy_doc_page.dart';
|
||||
import 'package:tma_app/features/mypage/presentation/pages/settings_page.dart';
|
||||
import 'package:tma_app/features/notifications/presentation/pages/notifications_page.dart';
|
||||
import 'package:tma_app/features/onboarding/presentation/pages/onboarding_page.dart';
|
||||
import 'package:tma_app/features/pet/presentation/pages/pet_info_page.dart';
|
||||
import 'package:tma_app/features/pet/presentation/pages/pet_register_page.dart';
|
||||
@@ -97,6 +98,11 @@ final appRouterProvider = Provider<GoRouter>((ref) {
|
||||
parentNavigatorKey: rootKey,
|
||||
builder: (context, state) => EventEditorPage.edit(eventId: state.uri.queryParameters['id'] ?? ''),
|
||||
),
|
||||
GoRoute(
|
||||
path: AppRoutes.notifications,
|
||||
parentNavigatorKey: rootKey,
|
||||
builder: (context, state) => const NotificationsPage(),
|
||||
),
|
||||
GoRoute(
|
||||
path: AppRoutes.diaryPattern,
|
||||
parentNavigatorKey: rootKey,
|
||||
|
||||
@@ -32,6 +32,9 @@ abstract final class AppRoutes {
|
||||
static String eventCreate(DateTime date) => '$eventCreatePath?date=${toIsoDate(date)}';
|
||||
static String eventEdit(String eventId) => '$eventEditPath?id=${Uri.encodeQueryComponent(eventId)}';
|
||||
|
||||
// 알림 — 홈 종에서 연다 (탭바 없이 전체 화면)
|
||||
static const String notifications = '/notifications';
|
||||
|
||||
// 다이어리 — 열면 탭바 없이 전체 화면
|
||||
static const String diaryPattern = '/diaries/:diaryId';
|
||||
static String diary(String id) => '/diaries/$id';
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
/// 지금 시각 — 테스트에서 특정 시각(예: 저녁 8시)을 재현할 수 있게 주입한다
|
||||
final clockProvider = Provider<DateTime Function()>((ref) => DateTime.now);
|
||||
@@ -8,13 +8,21 @@ import 'package:tma_app/features/auth/presentation/providers/user_provider.dart'
|
||||
import 'package:tma_app/features/diary/presentation/widgets/diary_shelf.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/notifications/presentation/providers/notice_providers.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/pressable.dart';
|
||||
import 'package:tma_app/shared/widgets/section_title.dart';
|
||||
|
||||
/// 탭바 뒤로 콘텐츠가 가려지지 않게 두는 여유 (시안 .rup-screen 아래 124 에서 탭바 몫을 뺀 값)
|
||||
const double _kBottomGap = 30;
|
||||
|
||||
/// 헤더 아이콘 버튼 크기 (시안 .rup-iconbtn)
|
||||
const double _kIconButton = 42;
|
||||
|
||||
/// 새 알림 점 (시안 .rup-iconbtn__dot — 테두리 포함 8)
|
||||
const double _kUnreadDot = 8;
|
||||
|
||||
/// 홈 — 인사말, 우리 아이, 오늘 일정·예약 일정, 다이어리
|
||||
class HomePage extends ConsumerWidget {
|
||||
const HomePage({super.key});
|
||||
@@ -76,13 +84,14 @@ class HomePage extends ConsumerWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _HomeHeader extends StatelessWidget {
|
||||
class _HomeHeader extends ConsumerWidget {
|
||||
const _HomeHeader({required this.nickname});
|
||||
|
||||
final String nickname;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final unread = ref.watch(hasUnreadNoticeProvider);
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(
|
||||
@@ -93,16 +102,32 @@ class _HomeHeader extends StatelessWidget {
|
||||
style: Theme.of(context).textTheme.headlineSmall,
|
||||
),
|
||||
),
|
||||
// 알림 화면은 흐름 순서상 뒤에 만든다 — 지금은 아이콘만 둔다
|
||||
const SizedBox.square(
|
||||
dimension: 42,
|
||||
child: Center(
|
||||
child: RupIcon(
|
||||
RupIconName.bell,
|
||||
size: 23,
|
||||
stroke: 2.2,
|
||||
color: AppColors.ink2,
|
||||
semanticLabel: '알림',
|
||||
Pressable(
|
||||
pressedScale: 0.9,
|
||||
semanticLabel: unread ? '알림, 새 알림 있음' : '알림',
|
||||
onTap: () => context.push(AppRoutes.notifications),
|
||||
child: SizedBox.square(
|
||||
dimension: _kIconButton,
|
||||
child: Stack(
|
||||
children: [
|
||||
const Center(
|
||||
child: RupIcon(RupIconName.bell, size: 23, stroke: 2.2, color: AppColors.ink2),
|
||||
),
|
||||
if (unread)
|
||||
Positioned(
|
||||
top: 9,
|
||||
right: 10,
|
||||
child: Container(
|
||||
width: _kUnreadDot,
|
||||
height: _kUnreadDot,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.primary,
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: AppColors.surface, width: 2),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'package:tma_app/features/diary/presentation/providers/diary_providers.da
|
||||
import 'package:tma_app/features/entry/presentation/providers/entry_provider.dart';
|
||||
import 'package:tma_app/features/mypage/data/app_settings_repository.dart';
|
||||
import 'package:tma_app/features/mypage/data/profile_repository.dart';
|
||||
import 'package:tma_app/features/notifications/presentation/providers/notice_providers.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';
|
||||
@@ -38,6 +39,7 @@ class AccountActions {
|
||||
..invalidate(carePresetsProvider)
|
||||
..invalidate(recordsTabProvider)
|
||||
..invalidate(appSettingsProvider)
|
||||
..invalidate(noticeSeenProvider)
|
||||
..invalidate(entryProvider);
|
||||
try {
|
||||
await photos.clearAll();
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import 'package:tma_app/core/utils/date_format.dart';
|
||||
|
||||
/// 알림 종류 — 다이어리 설정의 알림 스위치와 짝을 이룬다
|
||||
enum NoticeType {
|
||||
/// 가족이 새 글을 올렸다 (공유 다이어리 · 서버)
|
||||
newPost,
|
||||
|
||||
/// 내 기록에 댓글이 달렸다 (공유 다이어리 · 서버)
|
||||
newComment,
|
||||
|
||||
/// 오늘 기록을 아직 남기지 않았다 (기기에서 만든다)
|
||||
reminder,
|
||||
}
|
||||
|
||||
/// 알림 한 건
|
||||
class AppNotice {
|
||||
const AppNotice({
|
||||
required this.id,
|
||||
required this.type,
|
||||
required this.diaryId,
|
||||
required this.diaryName,
|
||||
required this.at,
|
||||
required this.text,
|
||||
this.who,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final NoticeType type;
|
||||
final String diaryId;
|
||||
final String diaryName;
|
||||
final DateTime at;
|
||||
|
||||
/// 알림 문구 — 새 글·댓글이면 그 내용, 기록 알림이면 안내 문구
|
||||
final String text;
|
||||
|
||||
/// 새 글·댓글을 남긴 사람 이름
|
||||
final String? who;
|
||||
}
|
||||
|
||||
/// 알림을 묶는 날 — 오늘 · 어제 · 최근 7일
|
||||
enum NoticeDay {
|
||||
today('오늘'),
|
||||
yesterday('어제'),
|
||||
week('최근 7일');
|
||||
|
||||
const NoticeDay(this.label);
|
||||
|
||||
final String label;
|
||||
|
||||
/// [at] 이 [now] 기준 어느 묶음인지 — 일주일이 넘었으면 null(보여 주지 않는다)
|
||||
static NoticeDay? of(DateTime at, DateTime now) {
|
||||
final days = DateTime.utc(now.year, now.month, now.day).difference(DateTime.utc(at.year, at.month, at.day)).inDays;
|
||||
if (days <= 0) return NoticeDay.today;
|
||||
if (days == 1) return NoticeDay.yesterday;
|
||||
if (days < 7) return NoticeDay.week;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// "방금 전" · "12분 전" · "3시간 전" · "어제" · "4일 전"
|
||||
String relativeTimeLabel(DateTime at, DateTime now) {
|
||||
final diff = now.difference(at);
|
||||
if (diff.inMinutes < 1) return '방금 전';
|
||||
if (dateOnly(at) == dateOnly(now)) {
|
||||
return diff.inHours < 1 ? '${diff.inMinutes}분 전' : '${diff.inHours}시간 전';
|
||||
}
|
||||
return relativeDayLabel(at, now);
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
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/clock.dart';
|
||||
import 'package:tma_app/features/notifications/domain/app_notice.dart';
|
||||
import 'package:tma_app/features/notifications/presentation/providers/notice_providers.dart';
|
||||
import 'package:tma_app/shared/widgets/icons/rup_icon.dart';
|
||||
import 'package:tma_app/shared/widgets/person_avatar.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/sub_page_header.dart';
|
||||
|
||||
const double _kAvatar = 38;
|
||||
|
||||
/// 알림 — 모든 다이어리의 소식을 날짜별로 모아 본다
|
||||
class NotificationsPage extends ConsumerStatefulWidget {
|
||||
const NotificationsPage({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<NotificationsPage> createState() => _NotificationsPageState();
|
||||
}
|
||||
|
||||
class _NotificationsPageState extends ConsumerState<NotificationsPage> {
|
||||
/// 들어오기 전에 마지막으로 본 시각 — 이번에 보는 동안은 새 알림 표시를 유지한다
|
||||
late final DateTime? _seenBefore = ref.read(noticeSeenProvider);
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// 들어온 순간 모두 읽은 것으로 — 홈 종의 점이 사라진다
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) unawaited(ref.read(noticeSeenProvider.notifier).markSeen(ref.read(clockProvider)()));
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final notices = ref.watch(noticesProvider);
|
||||
final now = ref.watch(clockProvider)();
|
||||
final groups = [
|
||||
for (final day in NoticeDay.values)
|
||||
(day: day, items: notices.where((n) => NoticeDay.of(n.at, now) == day).toList()),
|
||||
].where((g) => g.items.isNotEmpty).toList();
|
||||
final seen = _seenBefore;
|
||||
|
||||
return Scaffold(
|
||||
body: SafeArea(
|
||||
bottom: false,
|
||||
child: ListView(
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
AppSpacing.xl,
|
||||
AppSpacing.xs,
|
||||
AppSpacing.xl,
|
||||
MediaQuery.paddingOf(context).bottom + 40,
|
||||
),
|
||||
children: [
|
||||
SubPageHeader(
|
||||
title: '알림',
|
||||
subtitle: '우리 가족 다이어리 소식',
|
||||
displayFont: true,
|
||||
onBack: () => context.pop(),
|
||||
),
|
||||
if (groups.isEmpty)
|
||||
const RupCard(child: EmptyMessage('새로운 알림이 없어요', padding: 38))
|
||||
else
|
||||
for (final g in groups) ...[
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(AppSpacing.xs, 0, AppSpacing.xs, 9),
|
||||
child: Text(
|
||||
g.day.label,
|
||||
style: Theme.of(context).textTheme.bodyMedium!.copyWith(
|
||||
fontSize: 13.5,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.muted,
|
||||
height: AppTypography.normalHeight,
|
||||
),
|
||||
),
|
||||
),
|
||||
RupCard(
|
||||
child: Column(
|
||||
children: [
|
||||
for (final (i, n) in g.items.indexed)
|
||||
_NoticeRow(
|
||||
notice: n,
|
||||
now: now,
|
||||
first: i == 0,
|
||||
unread: seen == null || n.at.isAfter(seen),
|
||||
onTap: () => unawaited(context.push(AppRoutes.diary(n.diaryId))),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _NoticeRow extends StatelessWidget {
|
||||
const _NoticeRow({
|
||||
required this.notice,
|
||||
required this.now,
|
||||
required this.first,
|
||||
required this.unread,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final AppNotice notice;
|
||||
final DateTime now;
|
||||
final bool first;
|
||||
final bool unread;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
final n = notice;
|
||||
final who = TextSpan(text: n.who ?? '', style: const TextStyle(fontWeight: FontWeight.w700));
|
||||
final label = switch (n.type) {
|
||||
NoticeType.newPost => TextSpan(children: [who, const TextSpan(text: '님이 새 글을 올렸어요')]),
|
||||
NoticeType.newComment => TextSpan(children: [who, const TextSpan(text: '님이 회원님의 기록에 댓글을 남겼어요')]),
|
||||
NoticeType.reminder => TextSpan(text: n.text),
|
||||
};
|
||||
|
||||
return Semantics(
|
||||
container: true,
|
||||
button: true,
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 15, vertical: 13),
|
||||
decoration: BoxDecoration(
|
||||
color: unread ? AppColors.primary50 : Colors.transparent,
|
||||
border: first ? null : const Border(top: BorderSide(color: AppColors.line2)),
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (n.who != null)
|
||||
const PersonAvatar(size: _kAvatar)
|
||||
else
|
||||
Container(
|
||||
width: _kAvatar,
|
||||
height: _kAvatar,
|
||||
decoration: BoxDecoration(color: AppColors.accent.withValues(alpha: .16), shape: BoxShape.circle),
|
||||
alignment: Alignment.center,
|
||||
child: const RupIcon(RupIconName.paw, size: 20, stroke: 2, color: AppColors.accent),
|
||||
),
|
||||
const SizedBox(width: AppSpacing.md),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text.rich(label, style: textTheme.bodyMedium!.copyWith(fontSize: 14, height: 1.4, color: AppColors.ink)),
|
||||
if (n.type != NoticeType.reminder && n.text.isNotEmpty) ...[
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
'“${n.text}”',
|
||||
style: textTheme.bodySmall!.copyWith(fontSize: 12.5, height: 1.4, color: AppColors.ink2),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 3),
|
||||
Text(
|
||||
'${n.diaryName} · ${relativeTimeLabel(n.at, now)}',
|
||||
style: textTheme.bodySmall!.copyWith(
|
||||
fontSize: 11.5,
|
||||
color: AppColors.muted,
|
||||
height: AppTypography.normalHeight,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (unread) ...[
|
||||
const SizedBox(width: AppSpacing.md),
|
||||
Container(
|
||||
width: 8,
|
||||
height: 8,
|
||||
margin: const EdgeInsets.only(top: 6),
|
||||
decoration: const BoxDecoration(color: AppColors.primary, shape: BoxShape.circle),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:tma_app/core/storage/preferences_provider.dart';
|
||||
import 'package:tma_app/core/utils/clock.dart';
|
||||
import 'package:tma_app/core/utils/date_format.dart';
|
||||
import 'package:tma_app/features/diary/domain/diary.dart';
|
||||
import 'package:tma_app/features/diary/presentation/providers/diary_providers.dart';
|
||||
import 'package:tma_app/features/mypage/data/app_settings_repository.dart';
|
||||
import 'package:tma_app/features/notifications/domain/app_notice.dart';
|
||||
|
||||
const String _kSeenKey = 'notice.seenAt.v1';
|
||||
|
||||
/// 기록 알림 — 이 시각까지 오늘 기록이 없으면 알린다 (저녁 8시)
|
||||
const int kDiaryReminderHour = 20;
|
||||
|
||||
/// 기록 알림 문구
|
||||
const String kDiaryReminderText = '오늘 기록을 아직 남기지 않았어요. 잊기 전에 남겨 볼까요?';
|
||||
|
||||
/// 알림 화면을 마지막으로 본 시각 — 이후에 생긴 알림이 "새 알림"
|
||||
class NoticeSeenNotifier extends Notifier<DateTime?> {
|
||||
@override
|
||||
DateTime? build() => DateTime.tryParse(ref.watch(sharedPreferencesProvider).getString(_kSeenKey) ?? '');
|
||||
|
||||
Future<void> markSeen(DateTime at) async {
|
||||
state = at;
|
||||
await ref.read(sharedPreferencesProvider).setString(_kSeenKey, at.toIso8601String());
|
||||
}
|
||||
}
|
||||
|
||||
final noticeSeenProvider = NotifierProvider<NoticeSeenNotifier, DateTime?>(NoticeSeenNotifier.new);
|
||||
|
||||
/// 모든 다이어리의 알림 — 최근 것이 위로.
|
||||
/// 지금은 기기에서 만드는 기록 알림만 있다. 공유 다이어리의 새 글·댓글은 서버가 생기면 여기에 더한다.
|
||||
final noticesProvider = Provider<List<AppNotice>>((ref) {
|
||||
final now = ref.watch(clockProvider)();
|
||||
// 저녁 8시·자정에 다시 계산한다 — 앱을 켜 둔 채 시각이 지나도 기록 알림이 맞게 나타나고 사라진다
|
||||
final next = now.hour < kDiaryReminderHour
|
||||
? DateTime(now.year, now.month, now.day, kDiaryReminderHour)
|
||||
: DateTime(now.year, now.month, now.day + 1);
|
||||
final timer = Timer(next.difference(now), ref.invalidateSelf);
|
||||
ref.onDispose(timer.cancel);
|
||||
|
||||
// 마이페이지 "일정·다이어리 알림" 을 끄면 모두 끈다
|
||||
final settings = ref.watch(appSettingsProvider);
|
||||
if (settings[AppSetting.scheduleNotification] == false) return const [];
|
||||
|
||||
final book = ref.watch(diaryBookProvider);
|
||||
final notices = <AppNotice>[];
|
||||
if (now.hour >= kDiaryReminderHour) {
|
||||
final at = DateTime(now.year, now.month, now.day, kDiaryReminderHour);
|
||||
for (final d in book.diaries) {
|
||||
if (!d.notifies(DiaryNotice.reminder) || book.wroteOn(d.id, now)) continue;
|
||||
notices.add(AppNotice(
|
||||
id: 'reminder-${d.id}-${toIsoDate(now)}',
|
||||
type: NoticeType.reminder,
|
||||
diaryId: d.id,
|
||||
diaryName: d.name,
|
||||
at: at,
|
||||
text: kDiaryReminderText,
|
||||
));
|
||||
}
|
||||
}
|
||||
return notices..sort((a, b) => b.at.compareTo(a.at));
|
||||
});
|
||||
|
||||
/// 아직 보지 않은 알림이 있는지 — 홈 종에 점을 찍는다
|
||||
final hasUnreadNoticeProvider = Provider<bool>((ref) {
|
||||
final seen = ref.watch(noticeSeenProvider);
|
||||
return ref.watch(noticesProvider).any((n) => seen == null || n.at.isAfter(seen));
|
||||
});
|
||||
@@ -0,0 +1,165 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:tma_app/core/storage/preferences_provider.dart';
|
||||
import 'package:tma_app/core/utils/clock.dart';
|
||||
import 'package:tma_app/features/diary/domain/diary.dart';
|
||||
import 'package:tma_app/features/diary/domain/mood.dart';
|
||||
import 'package:tma_app/features/diary/presentation/providers/diary_providers.dart';
|
||||
import 'package:tma_app/features/mypage/data/app_settings_repository.dart';
|
||||
import 'package:tma_app/features/notifications/domain/app_notice.dart';
|
||||
import 'package:tma_app/features/notifications/presentation/providers/notice_providers.dart';
|
||||
|
||||
final DateTime _evening = DateTime(2026, 9, 28, 20, 30);
|
||||
|
||||
Diary _diary(String id, {Set<DiaryNotice> muted = const {}}) => Diary(
|
||||
id: id,
|
||||
name: '$id 일기',
|
||||
targetIds: const ['pet-1'],
|
||||
ribbon: kRibbonColors.first,
|
||||
tint: kDiaryTints.first,
|
||||
mutedNotices: muted,
|
||||
);
|
||||
|
||||
DiaryPost _post(String diaryId, DateTime date) => DiaryPost(
|
||||
id: 'p-$diaryId',
|
||||
diaryId: diaryId,
|
||||
date: DateTime(date.year, date.month, date.day),
|
||||
createdAt: date,
|
||||
text: '산책',
|
||||
mood: Mood.happy,
|
||||
);
|
||||
|
||||
/// 저장소에 다이어리·기록을 넣고, 시계를 [now] 로 고정한 컨테이너
|
||||
Future<ProviderContainer> _container({
|
||||
DateTime? now,
|
||||
List<Diary> diaries = const [],
|
||||
List<DiaryPost> posts = const [],
|
||||
Map<String, Object> extra = const {},
|
||||
}) async {
|
||||
SharedPreferences.setMockInitialValues({
|
||||
'diary.diaries.v1': jsonEncode([for (final d in diaries) d.toJson()]),
|
||||
'diary.posts.v1': jsonEncode([for (final p in posts) p.toJson()]),
|
||||
...extra,
|
||||
});
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final at = now ?? _evening;
|
||||
final container = ProviderContainer(
|
||||
overrides: [
|
||||
sharedPreferencesProvider.overrideWithValue(prefs),
|
||||
clockProvider.overrideWithValue(() => at),
|
||||
],
|
||||
);
|
||||
addTearDown(container.dispose);
|
||||
await container.read(diaryProvider.future);
|
||||
return container;
|
||||
}
|
||||
|
||||
void main() {
|
||||
group('NoticeDay.of', () {
|
||||
final now = DateTime(2026, 9, 28, 9);
|
||||
|
||||
test('같은 날은 오늘, 하루 전은 어제, 일주일 안은 최근 7일', () {
|
||||
expect(NoticeDay.of(DateTime(2026, 9, 28, 0, 5), now), NoticeDay.today);
|
||||
expect(NoticeDay.of(DateTime(2026, 9, 27, 23, 50), now), NoticeDay.yesterday);
|
||||
expect(NoticeDay.of(DateTime(2026, 9, 22, 12), now), NoticeDay.week);
|
||||
});
|
||||
|
||||
test('일주일이 넘은 알림은 묶음이 없다', () {
|
||||
expect(NoticeDay.of(DateTime(2026, 9, 21, 12), now), isNull);
|
||||
});
|
||||
|
||||
test('달이 바뀌어도 날짜 차이로 묶는다', () {
|
||||
expect(NoticeDay.of(DateTime(2026, 9, 30), DateTime(2026, 10, 1)), NoticeDay.yesterday);
|
||||
});
|
||||
});
|
||||
|
||||
group('relativeTimeLabel', () {
|
||||
final now = DateTime(2026, 9, 28, 20, 30);
|
||||
|
||||
test('오늘은 방금 전 · N분 전 · N시간 전', () {
|
||||
expect(relativeTimeLabel(now.subtract(const Duration(seconds: 20)), now), '방금 전');
|
||||
expect(relativeTimeLabel(DateTime(2026, 9, 28, 20), now), '30분 전');
|
||||
expect(relativeTimeLabel(DateTime(2026, 9, 28, 17, 10), now), '3시간 전');
|
||||
});
|
||||
|
||||
test('지난 날은 어제 · N일 전', () {
|
||||
expect(relativeTimeLabel(DateTime(2026, 9, 27, 23), now), '어제');
|
||||
expect(relativeTimeLabel(DateTime(2026, 9, 25, 9), now), '3일 전');
|
||||
});
|
||||
});
|
||||
|
||||
group('noticesProvider — 기록 알림', () {
|
||||
test('저녁 8시가 지나도록 오늘 기록이 없는 다이어리마다 알린다', () async {
|
||||
final c = await _container(diaries: [_diary('d1'), _diary('d2')]);
|
||||
final notices = c.read(noticesProvider);
|
||||
expect(notices.map((n) => n.diaryId), unorderedEquals(['d1', 'd2']));
|
||||
expect(notices.first.type, NoticeType.reminder);
|
||||
expect(notices.first.text, kDiaryReminderText);
|
||||
expect(notices.first.at, DateTime(2026, 9, 28, kDiaryReminderHour));
|
||||
});
|
||||
|
||||
test('저녁 8시 전에는 알리지 않는다', () async {
|
||||
final c = await _container(now: DateTime(2026, 9, 28, 19, 59), diaries: [_diary('d1')]);
|
||||
expect(c.read(noticesProvider), isEmpty);
|
||||
});
|
||||
|
||||
test('오늘 기록을 남긴 다이어리는 알리지 않는다', () async {
|
||||
final c = await _container(
|
||||
diaries: [_diary('d1'), _diary('d2')],
|
||||
posts: [_post('d1', DateTime(2026, 9, 28, 9))],
|
||||
);
|
||||
expect(c.read(noticesProvider).map((n) => n.diaryId), ['d2']);
|
||||
});
|
||||
|
||||
test('어제 기록은 오늘 기록으로 치지 않는다', () async {
|
||||
final c = await _container(diaries: [_diary('d1')], posts: [_post('d1', DateTime(2026, 9, 27, 21))]);
|
||||
expect(c.read(noticesProvider), hasLength(1));
|
||||
});
|
||||
|
||||
test('다이어리 설정에서 기록 알림을 끄면 알리지 않는다', () async {
|
||||
final c = await _container(diaries: [_diary('d1', muted: {DiaryNotice.reminder})]);
|
||||
expect(c.read(noticesProvider), isEmpty);
|
||||
});
|
||||
|
||||
test('마이페이지 일정·다이어리 알림을 끄면 모두 끈다', () async {
|
||||
final c = await _container(
|
||||
diaries: [_diary('d1')],
|
||||
extra: {AppSetting.scheduleNotification.key: false},
|
||||
);
|
||||
expect(c.read(noticesProvider), isEmpty);
|
||||
});
|
||||
});
|
||||
|
||||
group('새 알림 표시', () {
|
||||
test('한 번도 보지 않았으면 새 알림이 있다', () async {
|
||||
final c = await _container(diaries: [_diary('d1')]);
|
||||
expect(c.read(hasUnreadNoticeProvider), isTrue);
|
||||
});
|
||||
|
||||
test('알림 화면을 본 뒤에는 새 알림이 없고, 본 시각이 저장된다', () async {
|
||||
final c = await _container(diaries: [_diary('d1')]);
|
||||
await c.read(noticeSeenProvider.notifier).markSeen(_evening);
|
||||
expect(c.read(hasUnreadNoticeProvider), isFalse);
|
||||
|
||||
final prefs = c.read(sharedPreferencesProvider);
|
||||
expect(DateTime.parse(prefs.getString('notice.seenAt.v1')!), _evening);
|
||||
});
|
||||
|
||||
test('다음 날의 기록 알림은 다시 새 알림이다', () async {
|
||||
final c = await _container(
|
||||
now: DateTime(2026, 9, 29, 20, 10),
|
||||
diaries: [_diary('d1')],
|
||||
extra: {'notice.seenAt.v1': _evening.toIso8601String()},
|
||||
);
|
||||
expect(c.read(hasUnreadNoticeProvider), isTrue);
|
||||
});
|
||||
|
||||
test('알림이 없으면 새 알림도 없다', () async {
|
||||
final c = await _container(now: DateTime(2026, 9, 28, 10), diaries: [_diary('d1')]);
|
||||
expect(c.read(hasUnreadNoticeProvider), isFalse);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:tma_app/core/utils/clock.dart';
|
||||
import 'package:tma_app/features/diary/domain/diary.dart';
|
||||
import 'package:tma_app/features/diary/presentation/pages/diary_page.dart';
|
||||
import 'package:tma_app/features/notifications/presentation/pages/notifications_page.dart';
|
||||
import 'package:tma_app/features/notifications/presentation/providers/notice_providers.dart';
|
||||
|
||||
import 'support/app_harness.dart';
|
||||
|
||||
final Diary _diary = Diary(
|
||||
id: 'd1',
|
||||
name: '콩이 성장일기',
|
||||
targetIds: const ['pet-1'],
|
||||
ribbon: kRibbonColors.first,
|
||||
tint: kDiaryTints.first,
|
||||
);
|
||||
|
||||
final Map<String, Object> _prefs = {
|
||||
'pets.v1': jsonEncode([samplePet().toJson()]),
|
||||
'diary.diaries.v1': jsonEncode([_diary.toJson()]),
|
||||
};
|
||||
|
||||
/// 오늘 [hour]:30 으로 고정한 시계 — 앱의 다른 화면은 실제 날짜를 쓰므로 날짜는 오늘로 맞춘다
|
||||
DateTime _today(int hour) {
|
||||
final now = DateTime.now();
|
||||
return DateTime(now.year, now.month, now.day, hour, 30);
|
||||
}
|
||||
|
||||
Future<void> _launchAt(WidgetTester tester, int hour, {Map<String, Object> extra = const {}}) async {
|
||||
final at = _today(hour);
|
||||
await launchSignedIn(
|
||||
tester,
|
||||
extra: {..._prefs, ...extra},
|
||||
overrides: [clockProvider.overrideWithValue(() => at)],
|
||||
);
|
||||
}
|
||||
|
||||
void main() {
|
||||
testWidgets('저녁에 기록이 없으면 종에 새 알림 표시가 뜨고, 알림 화면에서 기록 알림을 본다', (tester) async {
|
||||
await _launchAt(tester, 20);
|
||||
expect(find.bySemanticsLabel('알림, 새 알림 있음'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.bySemanticsLabel('알림, 새 알림 있음'));
|
||||
await settle(tester);
|
||||
expect(find.byType(NotificationsPage), findsOneWidget);
|
||||
expect(find.text('우리 가족 다이어리 소식'), findsOneWidget);
|
||||
expect(find.text('오늘'), findsOneWidget);
|
||||
expect(find.text(kDiaryReminderText, findRichText: true), findsOneWidget);
|
||||
expect(find.text('콩이 성장일기 · 30분 전'), findsOneWidget);
|
||||
|
||||
// 보고 나오면 종의 새 알림 표시가 사라진다
|
||||
await tester.tap(find.bySemanticsLabel('뒤로'));
|
||||
await settle(tester);
|
||||
expect(find.bySemanticsLabel('알림'), findsOneWidget);
|
||||
expect(find.bySemanticsLabel('알림, 새 알림 있음'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('기록 알림을 누르면 그 다이어리로 간다', (tester) async {
|
||||
await _launchAt(tester, 21);
|
||||
await tester.tap(find.bySemanticsLabel('알림, 새 알림 있음'));
|
||||
await settle(tester);
|
||||
|
||||
await tester.tap(find.text(kDiaryReminderText, findRichText: true));
|
||||
await settle(tester);
|
||||
expect(find.byType(DiaryPage), findsOneWidget);
|
||||
expect(find.text('콩이 성장일기'), findsWidgets);
|
||||
});
|
||||
|
||||
testWidgets('알림이 없으면 빈 안내를 보여 준다', (tester) async {
|
||||
await _launchAt(tester, 10);
|
||||
expect(find.bySemanticsLabel('알림, 새 알림 있음'), findsNothing);
|
||||
|
||||
await tester.tap(find.bySemanticsLabel('알림'));
|
||||
await settle(tester);
|
||||
expect(find.text('새로운 알림이 없어요'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('다이어리 설정에서 기록 알림을 끄면 저녁에도 알리지 않는다', (tester) async {
|
||||
final muted = _diary.copyWith(mutedNotices: {DiaryNotice.reminder});
|
||||
await _launchAt(tester, 20, extra: {'diary.diaries.v1': jsonEncode([muted.toJson()])});
|
||||
expect(find.bySemanticsLabel('알림, 새 알림 있음'), findsNothing);
|
||||
});
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_riverpod/misc.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:tma_app/app.dart';
|
||||
@@ -28,8 +29,12 @@ Pet samplePet({String id = 'pet-1', String name = '콩이'}) => Pet.fromInput(
|
||||
);
|
||||
|
||||
/// 시안 크기 화면에서 로그인한 사용자로 앱을 띄우고 스플래시를 지나 홈까지 간다.
|
||||
/// 저장소 값을 확인할 수 있게 [SharedPreferences] 를 돌려준다.
|
||||
Future<SharedPreferences> launchSignedIn(WidgetTester tester, {Map<String, Object> extra = const {}}) async {
|
||||
/// 저장소 값을 확인할 수 있게 [SharedPreferences] 를 돌려준다. [overrides] 로 시계 등을 바꿔 끼운다.
|
||||
Future<SharedPreferences> launchSignedIn(
|
||||
WidgetTester tester, {
|
||||
Map<String, Object> extra = const {},
|
||||
List<Override> overrides = const [],
|
||||
}) async {
|
||||
tester.view.physicalSize = const Size(402, 874);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.reset);
|
||||
@@ -38,7 +43,7 @@ Future<SharedPreferences> launchSignedIn(WidgetTester tester, {Map<String, Objec
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [sharedPreferencesProvider.overrideWithValue(prefs)],
|
||||
overrides: [sharedPreferencesProvider.overrideWithValue(prefs), ...overrides],
|
||||
child: const RupApp(),
|
||||
),
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user