diff --git a/flutter/lib/core/router/app_router.dart b/flutter/lib/core/router/app_router.dart index dd7145f..916815a 100644 --- a/flutter/lib/core/router/app_router.dart +++ b/flutter/lib/core/router/app_router.dart @@ -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((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, diff --git a/flutter/lib/core/router/app_routes.dart b/flutter/lib/core/router/app_routes.dart index 73700d0..085b8b6 100644 --- a/flutter/lib/core/router/app_routes.dart +++ b/flutter/lib/core/router/app_routes.dart @@ -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'; diff --git a/flutter/lib/core/utils/clock.dart b/flutter/lib/core/utils/clock.dart new file mode 100644 index 0000000..9f39e21 --- /dev/null +++ b/flutter/lib/core/utils/clock.dart @@ -0,0 +1,4 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +/// 지금 시각 — 테스트에서 특정 시각(예: 저녁 8시)을 재현할 수 있게 주입한다 +final clockProvider = Provider((ref) => DateTime.now); diff --git a/flutter/lib/features/home/presentation/pages/home_page.dart b/flutter/lib/features/home/presentation/pages/home_page.dart index 54ebc76..bfb9ad8 100644 --- a/flutter/lib/features/home/presentation/pages/home_page.dart +++ b/flutter/lib/features/home/presentation/pages/home_page.dart @@ -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), + ), + ), + ), + ], ), ), ), diff --git a/flutter/lib/features/mypage/presentation/providers/account_actions.dart b/flutter/lib/features/mypage/presentation/providers/account_actions.dart index ce29bf8..1e97b15 100644 --- a/flutter/lib/features/mypage/presentation/providers/account_actions.dart +++ b/flutter/lib/features/mypage/presentation/providers/account_actions.dart @@ -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(); diff --git a/flutter/lib/features/notifications/domain/app_notice.dart b/flutter/lib/features/notifications/domain/app_notice.dart new file mode 100644 index 0000000..84816b3 --- /dev/null +++ b/flutter/lib/features/notifications/domain/app_notice.dart @@ -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); +} diff --git a/flutter/lib/features/notifications/presentation/pages/notifications_page.dart b/flutter/lib/features/notifications/presentation/pages/notifications_page.dart new file mode 100644 index 0000000..c8f7920 --- /dev/null +++ b/flutter/lib/features/notifications/presentation/pages/notifications_page.dart @@ -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 createState() => _NotificationsPageState(); +} + +class _NotificationsPageState extends ConsumerState { + /// 들어오기 전에 마지막으로 본 시각 — 이번에 보는 동안은 새 알림 표시를 유지한다 + 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), + ), + ], + ], + ), + ), + ), + ); + } +} diff --git a/flutter/lib/features/notifications/presentation/providers/notice_providers.dart b/flutter/lib/features/notifications/presentation/providers/notice_providers.dart new file mode 100644 index 0000000..3f1b428 --- /dev/null +++ b/flutter/lib/features/notifications/presentation/providers/notice_providers.dart @@ -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 { + @override + DateTime? build() => DateTime.tryParse(ref.watch(sharedPreferencesProvider).getString(_kSeenKey) ?? ''); + + Future markSeen(DateTime at) async { + state = at; + await ref.read(sharedPreferencesProvider).setString(_kSeenKey, at.toIso8601String()); + } +} + +final noticeSeenProvider = NotifierProvider(NoticeSeenNotifier.new); + +/// 모든 다이어리의 알림 — 최근 것이 위로. +/// 지금은 기기에서 만드는 기록 알림만 있다. 공유 다이어리의 새 글·댓글은 서버가 생기면 여기에 더한다. +final noticesProvider = Provider>((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 = []; + 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((ref) { + final seen = ref.watch(noticeSeenProvider); + return ref.watch(noticesProvider).any((n) => seen == null || n.at.isAfter(seen)); +}); diff --git a/flutter/test/notice_domain_test.dart b/flutter/test/notice_domain_test.dart new file mode 100644 index 0000000..caafb8c --- /dev/null +++ b/flutter/test/notice_domain_test.dart @@ -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 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 _container({ + DateTime? now, + List diaries = const [], + List posts = const [], + Map 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); + }); + }); +} diff --git a/flutter/test/notifications_flow_test.dart b/flutter/test/notifications_flow_test.dart new file mode 100644 index 0000000..bcfaa47 --- /dev/null +++ b/flutter/test/notifications_flow_test.dart @@ -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 _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 _launchAt(WidgetTester tester, int hour, {Map 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); + }); +} diff --git a/flutter/test/support/app_harness.dart b/flutter/test/support/app_harness.dart index 9c45aec..a50054f 100644 --- a/flutter/test/support/app_harness.dart +++ b/flutter/test/support/app_harness.dart @@ -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 launchSignedIn(WidgetTester tester, {Map extra = const {}}) async { +/// 저장소 값을 확인할 수 있게 [SharedPreferences] 를 돌려준다. [overrides] 로 시계 등을 바꿔 끼운다. +Future launchSignedIn( + WidgetTester tester, { + Map extra = const {}, + List overrides = const [], +}) async { tester.view.physicalSize = const Size(402, 874); tester.view.devicePixelRatio = 1; addTearDown(tester.view.reset); @@ -38,7 +43,7 @@ Future launchSignedIn(WidgetTester tester, {Map