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
@@ -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));
});