feat(flutter): 알림 화면과 홈 종 새 알림 표시 구현

시안의 알림 화면(오늘·어제·최근 7일 묶음)과 홈 종 버튼의 새 알림 점을 옮겼다.
서버가 아직 없어 공유 다이어리의 새 글·댓글 알림은 구조만 두고,
기기에서 만들 수 있는 기록 알림(저녁 8시까지 오늘 기록이 없는 다이어리)부터 보여 준다.
다이어리별 기록 알림 스위치와 마이페이지 일정·다이어리 알림 설정을 따른다.

시각에 따라 달라지는 동작을 테스트할 수 있게 clockProvider 로 현재 시각을 주입하고,
앱을 켜 둔 채 8시·자정이 지나도 알림이 맞게 바뀌도록 그 시각에 다시 계산한다.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-28 23:26:58 +09:00
parent d7f46d174a
commit 66fb329747
11 changed files with 648 additions and 15 deletions
+165
View File
@@ -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);
});
});
}
+85
View File
@@ -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);
});
}
+8 -3
View File
@@ -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(),
),
);