import 'dart:async'; import 'package:flutter/material.dart'; 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(); @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 createState() => _NotificationScreenState(); } class _NotificationScreenState extends State { List? _items; final Set _read = {}; @override void initState() { super.initState(); _load(); } Future _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); return 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: const Color(0xFF333333)), ), 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 _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(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.itemUsed => (const Color(0xFFCEBF3B), _icon('water_ec')), NotificationKind.quest => ( AppColors.cosmicSurfaceContainer, _icon('book_open'), ), }; AssetImage _icon(String name) => AssetImage('assets/icons/$name.png');