Files
fanuzu/app/lib/features/notification/notification_screen.dart

243 lines
9.0 KiB
Dart

import 'dart:async';
import 'package:flutter/material.dart';
import '../../core/push/push_messaging.dart' show PushTier;
import '../../core/theme/tokens.dart';
import '../../core/ui/app_icons.dart';
import 'notification_data.dart';
import '../../core/i18n/app_locale.dart';
/// 알림 종류·표기·그룹화와 저장소는 `notification_data.dart` 에 있다.
/// 이 화면을 임포트한 곳이 모델까지 함께 쓰므로 그대로 내보낸다.
export 'notification_data.dart';
/// 알림이 가리키는 문서 경로 → 열 화면. 앱 루트(`app.dart`)가 저장소를 쥐고 준다.
/// 화면을 만들지 못하는 경로(아직 이동 화면이 없는 종류)는 null — 누르면 읽음만 남는다.
class NotificationTargets extends InheritedWidget {
const NotificationTargets({
super.key,
required this.screenFor,
required super.child,
});
final Widget? Function(String targetPath) screenFor;
static NotificationTargets? maybeOf(BuildContext context) =>
context.dependOnInheritedWidgetOfExactType<NotificationTargets>();
@override
bool updateShouldNotify(NotificationTargets old) =>
old.screenFor != screenFor;
}
/// 알림 — 화면 대조 원장 F0 · Figma `229:11265`.
class NotificationScreen extends StatefulWidget {
const NotificationScreen({super.key, required this.repository});
final NotificationRepository repository;
@override
State<NotificationScreen> createState() => _NotificationScreenState();
}
class _NotificationScreenState extends State<NotificationScreen> {
List<AppNotification>? _items;
final Set<String> _read = {};
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
final items = await widget.repository.load();
if (!mounted) return;
setState(() {
_items = items;
_read
..clear()
..addAll(items.where((i) => i.read).map((i) => i.id));
});
}
@override
Widget build(BuildContext context) {
final t = Theme.of(context).textTheme;
final items = _items;
if (items == null) {
return const Scaffold(body: Center(child: CircularProgressIndicator()));
}
final groups = groupNotifications(items);
// 명세 `518:88347` — 뒤로가기는 *이전 화면 이동 및 읽음 상태값 반환*. 시스템 뒤로가기(Android
// 제스처 · iOS 스와이프)도 같은 값을 돌려준다.
return PopScope<Object?>(
canPop: false,
onPopInvokedWithResult: (didPop, _) {
if (!didPop) Navigator.of(context).pop({..._read});
},
child: Scaffold(
appBar: AppBar(
title: Text(context.l10n.homeNotificationsTooltip),
// 명세 — 뒤로가기 시 읽음 상태값을 돌려준다.
leading: BackButton(
onPressed: () => Navigator.of(context).pop({..._read}),
),
),
body: items.isEmpty
? Center(child: Text(context.l10n.notifEmpty, style: t.bodyMedium))
: ListView(
padding: const EdgeInsets.symmetric(horizontal: AppSpace.s4),
children: [
for (final group in groups)
// 시안 날짜 묶음 — 위아래 20 · 머리글과 항목 사이 20 · 항목 사이 24 · 아래 #1a1a1a 줄.
Container(
padding: const EdgeInsets.symmetric(vertical: 20),
foregroundDecoration: const BoxDecoration(
border: Border(
bottom: BorderSide(color: Color(0xFF1A1A1A)),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
group.label,
style: _style(_grey, height: 24 / 14),
),
for (final (i, item) in group.items.indexed) ...[
SizedBox(height: i == 0 ? 20 : 24),
_tile(item),
],
],
),
),
],
),
),
);
}
/// 명세 `518:88353` 종류별 이동 규칙 — 좋아요 · 댓글 → 게시물, 캠페인 달성 → 캠페인,
/// 퀘스트 완료 → 퀘스트 상세. 이클립스 · 아이템 사용은 이동하지 않는다.
Widget? _screenFor(AppNotification item) => switch (item.target) {
final path? => NotificationTargets.maybeOf(context)?.screenFor(path),
null => null,
};
Widget _tile(AppNotification item) {
final read = _read.contains(item.id);
final (fill, icon) = _badge(item.kind);
return InkWell(
onTap: () => _open(item),
child: Row(
children: [
Expanded(
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 30,
height: 30,
alignment: Alignment.center,
decoration: BoxDecoration(
color: fill,
shape: BoxShape.circle,
border: Border.all(color: _tierRing(item.tier)),
),
child: ImageIcon(icon, size: 14, color: AppColors.onCosmic),
),
const SizedBox(width: 13),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.title,
style: _style(
read ? AppColors.onCosmicVariant : AppColors.onCosmic,
),
),
const SizedBox(height: 6),
Text(
'${item.sourceLabel} · ${notificationTimestamp(item.at)}',
style: _style(_grey),
),
],
),
),
],
),
),
// 이동할 곳이 없는 알림에는 화살표를 그리지 않는다.
if (_screenFor(item) != null) ...[
const SizedBox(width: 13),
const ImageIcon(
AppIcons.chevronRight,
size: 24,
color: AppColors.onCosmicMuted,
),
],
],
),
);
}
Future<void> _open(AppNotification item) async {
// 처음 읽은 것만 서버에 알린다. 이미 읽은 알림을 다시 눌러도 쓰기가 없다.
final first = _read.add(item.id);
setState(() {});
if (first) {
// 읽음 표시가 실패해도 이동을 막지 않는다. 다음 진입에서 서버 값으로
// 되돌아오므로 잃는 것이 없고, 여기서 막으면 알림 자체를 못 읽는다.
unawaited(widget.repository.markRead(item.id).catchError((_) {}));
}
final screen = _screenFor(item);
if (screen == null || !mounted) return;
await Navigator.of(
context,
).push<void>(MaterialPageRoute(builder: (_) => screen));
}
}
const _grey = Color(0xFF777777);
/// 시안 알림 글자 — 42dot 14/600 · 자간 -2.5% · 행간 130%.
TextStyle _style(Color color, {double height = 1.3}) => TextStyle(
fontFamily: AppFonts.body,
fontSize: 14,
fontWeight: FontWeight.w600,
height: height,
letterSpacing: -0.35,
color: color,
);
/// 종류별 원 배지 — 시안 30 원 · #333333 획. 댓글·이클립스는 검정, 좋아요는 #ff5151,
/// 아이템은 #cebf3b, 퀘스트는 #1a1a1c. 캠페인 달성은 시안이 없어 댓글과 같은 검정이다.
(Color, ImageProvider) _badge(NotificationKind kind) => switch (kind) {
NotificationKind.comment => (Colors.black, _icon('message_circle')),
NotificationKind.campaign => (Colors.black, _icon('message_circle')),
NotificationKind.like => (const Color(0xFFFF5151), _icon('heart')),
NotificationKind.eclipse => (Colors.black, _icon('planet')),
NotificationKind.evolution => (Colors.black, _icon('planet')),
NotificationKind.itemUsed => (const Color(0xFFCEBF3B), _icon('water_ec')),
NotificationKind.quest => (
AppColors.cosmicSurfaceContainer,
_icon('book_open'),
),
};
/// 정책 원장 C-78 위계 — 위기 · 공동행동 · 성취 · 결과(v1.5 8장 「알림 우선순위 표현」). Figma 는 배지 획이 모두
/// #333333 이라 결과 · 그 밖은 그대로 두고, 위 세 단계만 획 색으로 올린다.
/// 색은 **임시값** — 자료에 위계별 색 정의가 없어 기존 토큰(위기 = 경고 적색 · 공동행동 = 강조 · 성취 = 금)을 쓴다.
Color _tierRing(PushTier tier) => switch (tier) {
PushTier.crisis => AppColors.neonMagenta,
PushTier.collective => AppColors.cosmicAccent,
PushTier.achievement => AppColors.gold,
PushTier.result || PushTier.other => const Color(0xFF333333),
};
AssetImage _icon(String name) => AssetImage('assets/icons/$name.png');