Files
jamshalat-diary/lib/features/notifications/presentation/notification_center_screen.dart
2026-03-18 00:07:10 +07:00

880 lines
29 KiB
Dart

import 'dart:async';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:intl/intl.dart';
import '../../../app/icons/app_icons.dart';
import '../../../app/theme/app_colors.dart';
import '../../../data/services/notification_analytics_service.dart';
import '../../../data/services/notification_inbox_service.dart';
import '../../../data/services/notification_service.dart';
class NotificationCenterScreen extends StatefulWidget {
const NotificationCenterScreen({super.key});
@override
State<NotificationCenterScreen> createState() =>
_NotificationCenterScreenState();
}
class _NotificationCenterScreenState extends State<NotificationCenterScreen>
with TickerProviderStateMixin {
late final TabController _tabController;
late Future<List<NotificationPendingAlert>> _alarmsFuture;
@override
void initState() {
super.initState();
_tabController = TabController(length: 2, vsync: this);
unawaited(NotificationInboxService.instance.removeByType('prayer'));
_alarmsFuture = NotificationService.instance.pendingAlerts();
NotificationAnalyticsService.instance.track(
'notif_inbox_opened',
dimensions: const <String, dynamic>{'screen': 'notification_center'},
);
}
@override
void dispose() {
_tabController.dispose();
super.dispose();
}
Future<void> _refreshAlarms() async {
setState(() {
_alarmsFuture = NotificationService.instance.pendingAlerts();
});
await _alarmsFuture;
}
Future<void> _markAllRead() async {
await NotificationInboxService.instance.markAllRead();
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Semua pesan sudah ditandai terbaca.')),
);
}
@override
Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
final inboxListenable = NotificationInboxService.instance.listenable();
return Scaffold(
appBar: AppBar(
leading: IconButton(
onPressed: () => context.pop(),
icon: const AppIcon(glyph: AppIcons.backArrow),
),
title: const Text('Pemberitahuan'),
centerTitle: false,
actions: [
ListenableBuilder(
listenable: _tabController.animation!,
builder: (context, _) {
final tabIndex = _tabController.index;
if (tabIndex == 0) {
return IconButton(
onPressed: _refreshAlarms,
icon: Icon(
Icons.refresh_rounded,
color: isDark
? AppColors.textSecondaryDark
: AppColors.textSecondaryLight,
),
);
}
return ValueListenableBuilder(
valueListenable: inboxListenable,
builder: (context, _, __) {
final unread =
NotificationInboxService.instance.unreadCount();
if (unread <= 0) return const SizedBox.shrink();
return TextButton(
onPressed: _markAllRead,
child: const Text('Tandai semua'),
);
},
);
},
),
const SizedBox(width: 6),
],
bottom: TabBar(
controller: _tabController,
indicatorColor: AppColors.primary,
labelColor: AppColors.primary,
unselectedLabelColor: isDark
? AppColors.textSecondaryDark
: AppColors.textSecondaryLight,
tabs: [
FutureBuilder<List<NotificationPendingAlert>>(
future: _alarmsFuture,
builder: (context, snapshot) {
final count = snapshot.data?.length ?? 0;
return Tab(text: count > 0 ? 'Alarm ($count)' : 'Alarm');
},
),
ValueListenableBuilder(
valueListenable: inboxListenable,
builder: (context, _, __) {
final unread = NotificationInboxService.instance.unreadCount();
return Tab(text: unread > 0 ? 'Pesan ($unread)' : 'Pesan');
},
),
],
),
),
body: SafeArea(
top: false,
child: TabBarView(
controller: _tabController,
children: [
_AlarmTab(future: _alarmsFuture, onRefresh: _refreshAlarms),
_InboxTab(),
],
),
),
);
}
}
class _AlarmTab extends StatefulWidget {
const _AlarmTab({
required this.future,
required this.onRefresh,
});
final Future<List<NotificationPendingAlert>> future;
final Future<void> Function() onRefresh;
@override
State<_AlarmTab> createState() => _AlarmTabState();
}
class _AlarmTabState extends State<_AlarmTab> {
String _alarmFilter = 'upcoming';
@override
Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
return FutureBuilder<List<NotificationPendingAlert>>(
future: widget.future,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
final alarms = snapshot.data ?? const <NotificationPendingAlert>[];
final now = DateTime.now();
final upcoming = alarms
.where((alarm) =>
alarm.scheduledAt == null || !alarm.scheduledAt!.isBefore(now))
.toList();
final passed = alarms
.where((alarm) =>
alarm.scheduledAt != null && alarm.scheduledAt!.isBefore(now))
.toList();
final visible = _alarmFilter == 'past' ? passed : upcoming;
if (alarms.isEmpty) {
return RefreshIndicator(
onRefresh: widget.onRefresh,
child: ListView(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.fromLTRB(16, 16, 16, 24),
children: [
_buildAlarmFilters(
isDark: isDark,
upcomingCount: 0,
passedCount: 0,
),
const SizedBox(height: 20),
AppIcon(
glyph: AppIcons.notification,
size: 40,
color: isDark
? AppColors.textSecondaryDark
: AppColors.textSecondaryLight,
),
const SizedBox(height: 12),
Text(
'Belum ada alarm aktif',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 8),
Text(
'Alarm adzan dan iqamah akan muncul di sini saat sudah dijadwalkan.',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: isDark
? AppColors.textSecondaryDark
: AppColors.textSecondaryLight,
),
),
],
),
);
}
return RefreshIndicator(
onRefresh: widget.onRefresh,
child: ListView(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.fromLTRB(16, 16, 16, 24),
children: [
_buildAlarmFilters(
isDark: isDark,
upcomingCount: upcoming.length,
passedCount: passed.length,
),
const SizedBox(height: 12),
if (visible.isEmpty)
Container(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 24,
),
decoration: BoxDecoration(
color: isDark
? AppColors.surfaceDarkElevated
: AppColors.surfaceLight,
borderRadius: BorderRadius.circular(14),
border: Border.all(
color: isDark
? AppColors.primary.withValues(alpha: 0.14)
: AppColors.cream,
),
),
child: Text(
_alarmFilter == 'upcoming'
? 'Tidak ada alarm akan datang.'
: 'Belum ada alarm sudah lewat.',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: isDark
? AppColors.textSecondaryDark
: AppColors.textSecondaryLight,
),
),
),
for (final alarm in visible) ...[
_buildAlarmItem(context, isDark: isDark, alarm: alarm),
const SizedBox(height: 10),
],
],
),
);
},
);
}
Widget _buildAlarmItem(
BuildContext context, {
required bool isDark,
required NotificationPendingAlert alarm,
}) {
final chipColor = _chipColor(alarm.type);
final when = _formatAlarmTime(alarm.scheduledAt);
return Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: isDark ? AppColors.surfaceDarkElevated : AppColors.surfaceLight,
borderRadius: BorderRadius.circular(14),
border: Border.all(
color: isDark
? AppColors.primary.withValues(alpha: 0.16)
: AppColors.cream,
),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: chipColor.withValues(alpha: 0.14),
borderRadius: BorderRadius.circular(12),
),
child: Center(
child: AppIcon(
glyph: AppIcons.notification,
size: 18,
color: chipColor,
),
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
alarm.title,
style: Theme.of(context).textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 4),
if (alarm.body.isNotEmpty)
Text(
alarm.body,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: isDark
? AppColors.textSecondaryDark
: AppColors.textSecondaryLight,
),
),
const SizedBox(height: 8),
Row(
children: [
_TypeBadge(
label: _alarmLabel(alarm.type),
color: chipColor,
),
const SizedBox(width: 8),
Text(
when,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: isDark
? AppColors.textSecondaryDark
: AppColors.textSecondaryLight,
),
),
],
),
],
),
),
],
),
);
}
Widget _buildAlarmFilters({
required bool isDark,
required int upcomingCount,
required int passedCount,
}) {
return Wrap(
spacing: 8,
runSpacing: 8,
children: [
_FilterChip(
label: upcomingCount > 0
? 'Akan Datang ($upcomingCount)'
: 'Akan Datang',
selected: _alarmFilter == 'upcoming',
isDark: isDark,
onTap: () => setState(() => _alarmFilter = 'upcoming'),
),
_FilterChip(
label: passedCount > 0 ? 'Sudah Lewat ($passedCount)' : 'Sudah Lewat',
selected: _alarmFilter == 'past',
isDark: isDark,
onTap: () => setState(() => _alarmFilter = 'past'),
),
],
);
}
Color _chipColor(String type) {
switch (type) {
case 'adhan':
return AppColors.primary;
case 'iqamah':
return const Color(0xFF7B61FF);
case 'checklist':
return const Color(0xFF2D98DA);
case 'system':
return const Color(0xFFE17055);
default:
return AppColors.sage;
}
}
String _alarmLabel(String type) {
switch (type) {
case 'adhan':
return 'Adzan';
case 'iqamah':
return 'Iqamah';
case 'checklist':
return 'Checklist';
case 'system':
return 'Sistem';
default:
return 'Alarm';
}
}
String _formatAlarmTime(DateTime? value) {
if (value == null) return 'Waktu tidak diketahui';
try {
return DateFormat('EEE, d MMM • HH:mm', 'id_ID').format(value);
} catch (_) {
return DateFormat('d/MM • HH:mm').format(value);
}
}
}
class _InboxTab extends StatefulWidget {
@override
State<_InboxTab> createState() => _InboxTabState();
}
class _InboxTabState extends State<_InboxTab> {
String _filter = 'all';
@override
Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
final inbox = NotificationInboxService.instance;
return ValueListenableBuilder(
valueListenable: inbox.listenable(),
builder: (context, _, __) {
final items = inbox.allItems(filter: _filter);
if (items.isEmpty) {
return ListView(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.fromLTRB(16, 16, 16, 24),
children: [
_buildFilters(isDark),
const SizedBox(height: 20),
AppIcon(
glyph: AppIcons.notification,
size: 40,
color: isDark
? AppColors.textSecondaryDark
: AppColors.textSecondaryLight,
),
const SizedBox(height: 12),
Text(
'Belum ada pesan',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 8),
Text(
'Pesan sistem dan ringkasan ibadah akan muncul di sini.',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: isDark
? AppColors.textSecondaryDark
: AppColors.textSecondaryLight,
),
),
],
);
}
return ListView(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 24),
children: [
_buildFilters(isDark),
const SizedBox(height: 12),
...items.map((item) {
final accent = _inboxAccent(item.type);
return Padding(
padding: const EdgeInsets.only(bottom: 10),
child: Dismissible(
key: ValueKey(item.id),
background: _swipeBackground(
isDark: isDark,
icon: item.isRead ? Icons.mark_email_unread : Icons.done,
label: item.isRead ? 'Belum dibaca' : 'Tandai dibaca',
alignment: Alignment.centerLeft,
color: AppColors.primary,
),
secondaryBackground: _swipeBackground(
isDark: isDark,
icon: Icons.delete_outline,
label: 'Hapus',
alignment: Alignment.centerRight,
color: AppColors.errorLight,
),
confirmDismiss: (direction) async {
if (direction == DismissDirection.startToEnd) {
if (item.isRead) {
await inbox.markUnread(item.id);
} else {
await inbox.markRead(item.id);
}
return false;
}
await inbox.remove(item.id);
return true;
},
child: InkWell(
borderRadius: BorderRadius.circular(14),
onTap: () async {
if (!item.isRead) {
await inbox.markRead(item.id);
}
await NotificationAnalyticsService.instance.track(
'notif_inbox_opened',
dimensions: <String, dynamic>{
'event_type': item.type,
'deeplink': item.deeplink ?? '',
},
);
if (!context.mounted) return;
final deeplink = item.deeplink;
if (deeplink != null && deeplink.isNotEmpty) {
if (deeplink.startsWith('/')) {
context.go(deeplink);
} else {
context.push(deeplink);
}
}
},
child: Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: isDark
? AppColors.surfaceDarkElevated
: AppColors.surfaceLight,
borderRadius: BorderRadius.circular(14),
border: Border.all(
color: !item.isRead
? accent.withValues(alpha: isDark ? 0.4 : 0.32)
: (isDark
? AppColors.primary.withValues(alpha: 0.14)
: AppColors.cream),
),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: accent.withValues(alpha: 0.14),
borderRadius: BorderRadius.circular(12),
),
child: Center(
child: AppIcon(
glyph: _inboxGlyph(item.type),
size: 18,
color: accent,
),
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
item.title,
style: Theme.of(context)
.textTheme
.titleSmall
?.copyWith(
fontWeight: FontWeight.w700),
),
),
if (item.isPinned)
const Padding(
padding: EdgeInsets.only(right: 6),
child: Icon(
Icons.push_pin_rounded,
size: 15,
color: AppColors.navActiveGoldDeep,
),
),
if (!item.isRead)
Container(
width: 8,
height: 8,
decoration: const BoxDecoration(
color: AppColors.primary,
shape: BoxShape.circle,
),
),
],
),
const SizedBox(height: 6),
Text(
item.body,
style: Theme.of(context)
.textTheme
.bodyMedium
?.copyWith(
color: isDark
? AppColors.textSecondaryDark
: AppColors.textSecondaryLight,
),
),
const SizedBox(height: 8),
Row(
children: [
_TypeBadge(
label: _inboxLabel(item.type),
color: accent,
),
const SizedBox(width: 8),
Text(
_formatInboxTime(item.createdAt),
style: Theme.of(context)
.textTheme
.bodySmall
?.copyWith(
color: isDark
? AppColors.textSecondaryDark
: AppColors.textSecondaryLight,
),
),
],
),
],
),
),
IconButton(
onPressed: () => inbox.togglePinned(item.id),
icon: Icon(
item.isPinned
? Icons.push_pin_rounded
: Icons.push_pin_outlined,
size: 18,
color: item.isPinned
? AppColors.navActiveGoldDeep
: (isDark
? AppColors.textSecondaryDark
: AppColors.textSecondaryLight),
),
),
],
),
),
),
),
);
}),
],
);
},
);
}
Widget _buildFilters(bool isDark) {
return Wrap(
spacing: 8,
runSpacing: 8,
children: [
_FilterChip(
label: 'Semua',
selected: _filter == 'all',
isDark: isDark,
onTap: () => setState(() => _filter = 'all'),
),
_FilterChip(
label: 'Belum Dibaca',
selected: _filter == 'unread',
isDark: isDark,
onTap: () => setState(() => _filter = 'unread'),
),
_FilterChip(
label: 'Sistem',
selected: _filter == 'system',
isDark: isDark,
onTap: () => setState(() => _filter = 'system'),
),
],
);
}
Widget _swipeBackground({
required bool isDark,
required IconData icon,
required String label,
required Alignment alignment,
required Color color,
}) {
return Container(
alignment: alignment,
padding: const EdgeInsets.symmetric(horizontal: 16),
decoration: BoxDecoration(
color: color.withValues(alpha: isDark ? 0.18 : 0.12),
borderRadius: BorderRadius.circular(14),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 18, color: color),
const SizedBox(width: 6),
Text(
label,
style: TextStyle(
color: color,
fontWeight: FontWeight.w700,
fontSize: 12,
),
),
],
),
);
}
AppIconGlyph _inboxGlyph(String type) {
switch (type) {
case 'system':
return AppIcons.settings;
case 'summary':
case 'streak_risk':
return AppIcons.laporan;
case 'prayer':
return AppIcons.notification;
case 'content':
return AppIcons.notification;
default:
return AppIcons.notification;
}
}
Color _inboxAccent(String type) {
switch (type) {
case 'system':
return const Color(0xFFE17055);
case 'summary':
return const Color(0xFF7B61FF);
case 'prayer':
return AppColors.primary;
case 'content':
return const Color(0xFF00CEC9);
default:
return AppColors.sage;
}
}
String _inboxLabel(String type) {
switch (type) {
case 'system':
return 'Sistem';
case 'summary':
return 'Ringkasan';
case 'streak_risk':
return 'Pengingat';
case 'prayer':
return 'Sholat';
case 'content':
return 'Konten';
default:
return 'Pesan';
}
}
String _formatInboxTime(DateTime value) {
final now = DateTime.now();
final isToday = now.year == value.year &&
now.month == value.month &&
now.day == value.day;
try {
if (isToday) {
return DateFormat('HH:mm', 'id_ID').format(value);
}
return DateFormat('d MMM • HH:mm', 'id_ID').format(value);
} catch (_) {
if (isToday) {
return DateFormat('HH:mm').format(value);
}
return DateFormat('d/MM • HH:mm').format(value);
}
}
}
class _FilterChip extends StatelessWidget {
const _FilterChip({
required this.label,
required this.selected,
required this.isDark,
required this.onTap,
});
final String label;
final bool selected;
final bool isDark;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(999),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: selected
? AppColors.primary.withValues(alpha: isDark ? 0.22 : 0.16)
: (isDark
? AppColors.surfaceDarkElevated
: AppColors.surfaceLightElevated),
borderRadius: BorderRadius.circular(999),
border: Border.all(
color: selected
? AppColors.primary
: (isDark
? AppColors.primary.withValues(alpha: 0.2)
: AppColors.cream),
),
),
child: Text(
label,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w700,
color: selected
? AppColors.primary
: (isDark
? AppColors.textSecondaryDark
: AppColors.textSecondaryLight),
),
),
),
);
}
}
class _TypeBadge extends StatelessWidget {
const _TypeBadge({
required this.label,
required this.color,
});
final String label;
final Color color;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.14),
borderRadius: BorderRadius.circular(10),
),
child: Text(
label,
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w700,
color: color,
),
),
);
}
}