// lib/features/notifications/notification_screen.dart // ignore_for_file: use_build_context_synchronously import 'dart:async'; import 'package:dio/dio.dart'; import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; import '../../app/injection_container.dart'; import '../../core/errors/friendly_error.dart'; import '../../core/network/api_client.dart'; import '../../core/services/tts_service.dart'; import '../../core/theme/app_colors.dart'; Dio get _api => sl().dio; class NotificationScreen extends StatefulWidget { const NotificationScreen({super.key}); @override State createState() => _NotificationScreenState(); } class _NotificationScreenState extends State { List<_NotifItem> _items = []; bool _loading = true; String? _error; bool _markingAll = false; @override void initState() { super.initState(); _load(); } Future _load() async { setState(() { _loading = true; _error = null; }); await runFriendlyAction( () async { final res = await _api .get('/user/notifications') .timeout(const Duration(seconds: 10)); final list = _extractList(res.data); setState(() { _items = list.map((e) => _NotifItem.fromJson(e)).toList(); }); }, onError: (message) => setState(() => _error = message), fallback: 'Gagal memuat notifikasi. Coba refresh lagi.', ); if (mounted) setState(() => _loading = false); } List> _extractList(dynamic responseBody) { final data = responseBody is Map ? responseBody['data'] : null; final rawList = data is Map ? data['content'] : data; if (rawList is! List) return const []; return rawList .whereType() .map((item) => Map.from(item)) .toList(); } Future _markRead(int id) async { await runFriendlyAction( () async { await _api .put('/user/notifications/$id/read') .timeout(const Duration(seconds: 6)); setState(() { final idx = _items.indexWhere((n) => n.id == id); if (idx != -1) _items[idx] = _items[idx].copyWith(isRead: true); }); }, onError: (_) {}, fallback: 'Gagal menandai notifikasi.', ); } Future _markAllRead() async { setState(() => _markingAll = true); await runFriendlyAction( () async { await _api .put('/user/notifications/mark-all-read') .timeout(const Duration(seconds: 8)); setState(() { _items = _items.map((n) => n.copyWith(isRead: true)).toList(); }); _snack('Semua notifikasi ditandai sudah dibaca.'); }, onError: _snack, fallback: 'Gagal menandai semua dibaca.', ); if (mounted) setState(() => _markingAll = false); } Future _readAloud(_NotifItem notif) async { final tts = sl(); tts.speak(notif.content ?? 'Voice note dari Guardian.'); await _markRead(notif.id); } void _snack(String msg) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(msg))); } } int get _unreadCount => _items.where((n) => !n.isRead).length; @override Widget build(BuildContext context) { return SafeArea( child: Padding( padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // Header Row( children: [ Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Text( 'Notifications', style: Theme.of(context) .textTheme .headlineSmall ?.copyWith(fontWeight: FontWeight.w800), ), if (_unreadCount > 0) ...[ const SizedBox(width: 8), _UnreadBadge(count: _unreadCount), ], ], ), const Text( 'Pesan dari Guardian kamu', style: TextStyle(color: AppColors.muted), ), ], ), ), if (_unreadCount > 0) TextButton.icon( onPressed: _markingAll ? null : _markAllRead, icon: _markingAll ? const SizedBox( width: 14, height: 14, child: CircularProgressIndicator(strokeWidth: 2)) : const Icon(Icons.done_all, size: 18), label: const Text('Baca semua'), ), IconButton( onPressed: _load, icon: const Icon(Icons.refresh), tooltip: 'Refresh', ), ], ), const SizedBox(height: 16), // Body Expanded( child: _loading ? const Center(child: CircularProgressIndicator()) : _error != null ? _ErrorPanel(message: _error!, onRetry: _load) : _items.isEmpty ? const _EmptyPanel() : RefreshIndicator( onRefresh: _load, child: ListView.separated( itemCount: _items.length, separatorBuilder: (_, __) => const SizedBox(height: 10), itemBuilder: (ctx, i) => _NotifCard( notif: _items[i], onMarkRead: () => _markRead(_items[i].id), onReadAloud: () => _readAloud(_items[i]), ), ), ), ), ], ), ), ); } } // ─── Data model ──────────────────────────────────────────────────────────────── class _NotifItem { final int id; final String type; // 'TEXT' | 'VOICE_NOTE' final String? content; final String? voiceNoteUrl; final bool isRead; final DateTime createdAt; const _NotifItem({ required this.id, required this.type, this.content, this.voiceNoteUrl, required this.isRead, required this.createdAt, }); factory _NotifItem.fromJson(Map j) => _NotifItem( id: j['id'] as int, type: j['notifType']?.toString() ?? 'TEXT', content: j['content']?.toString(), voiceNoteUrl: j['voiceNoteUrl']?.toString(), isRead: j['isRead'] == true, createdAt: DateTime.tryParse(j['createdAt']?.toString() ?? '') ?? DateTime.now(), ); _NotifItem copyWith({bool? isRead}) => _NotifItem( id: id, type: type, content: content, voiceNoteUrl: voiceNoteUrl, isRead: isRead ?? this.isRead, createdAt: createdAt, ); } // ─── Card ────────────────────────────────────────────────────────────────────── class _NotifCard extends StatelessWidget { final _NotifItem notif; final VoidCallback onMarkRead; final VoidCallback onReadAloud; const _NotifCard({ required this.notif, required this.onMarkRead, required this.onReadAloud, }); @override Widget build(BuildContext context) { final isVoice = notif.type == 'VOICE_NOTE'; final unread = !notif.isRead; return AnimatedContainer( duration: const Duration(milliseconds: 300), decoration: BoxDecoration( color: unread ? const Color(0xFFEFF6FF) : Colors.white, borderRadius: BorderRadius.circular(14), border: Border.all( color: unread ? AppColors.primary.withValues(alpha: 0.3) : const Color(0xFFE2E8F0), width: unread ? 1.5 : 1, ), boxShadow: [ BoxShadow( color: Colors.black.withValues(alpha: 0.04), blurRadius: 8, offset: const Offset(0, 2), ), ], ), child: Padding( padding: const EdgeInsets.all(14), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ // Type icon Container( width: 36, height: 36, decoration: BoxDecoration( color: isVoice ? AppColors.success.withValues(alpha: 0.12) : AppColors.primary.withValues(alpha: 0.12), borderRadius: BorderRadius.circular(10), ), child: Icon( isVoice ? Icons.mic : Icons.message, color: isVoice ? AppColors.success : AppColors.primary, size: 18, ), ), const SizedBox(width: 10), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( isVoice ? 'Voice Note' : 'Pesan Teks', style: TextStyle( fontWeight: FontWeight.w700, color: isVoice ? AppColors.success : AppColors.primary, fontSize: 13, ), ), Text( _formatTime(notif.createdAt), style: const TextStyle( color: AppColors.muted, fontSize: 12), ), ], ), ), if (unread) Container( width: 10, height: 10, decoration: const BoxDecoration( color: AppColors.primary, shape: BoxShape.circle, ), ), ], ), if (notif.content != null && notif.content!.isNotEmpty) ...[ const SizedBox(height: 10), Text( notif.content!, style: const TextStyle(fontSize: 14, height: 1.5), ), ], const SizedBox(height: 12), Row( children: [ // Read aloud button OutlinedButton.icon( onPressed: onReadAloud, icon: const Icon(Icons.volume_up, size: 16), label: const Text('Bacakan', style: TextStyle(fontSize: 13)), style: OutlinedButton.styleFrom( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), minimumSize: Size.zero, tapTargetSize: MaterialTapTargetSize.shrinkWrap, ), ), const SizedBox(width: 8), if (unread) TextButton.icon( onPressed: onMarkRead, icon: const Icon(Icons.check, size: 16), label: const Text('Tandai dibaca', style: TextStyle(fontSize: 13)), style: TextButton.styleFrom( padding: const EdgeInsets.symmetric( horizontal: 12, vertical: 6), minimumSize: Size.zero, tapTargetSize: MaterialTapTargetSize.shrinkWrap, ), ), ], ), ], ), ), ); } String _formatTime(DateTime dt) { final now = DateTime.now(); final diff = now.difference(dt); if (diff.inMinutes < 1) return 'Baru saja'; if (diff.inHours < 1) return '${diff.inMinutes} menit lalu'; if (diff.inDays < 1) return '${diff.inHours} jam lalu'; return DateFormat('dd MMM, HH:mm').format(dt); } } // ─── Helper widgets ──────────────────────────────────────────────────────────── class _UnreadBadge extends StatelessWidget { final int count; const _UnreadBadge({required this.count}); @override Widget build(BuildContext context) { return Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), decoration: BoxDecoration( color: AppColors.primary, borderRadius: BorderRadius.circular(999), ), child: Text( '$count', style: const TextStyle( color: Colors.white, fontSize: 12, fontWeight: FontWeight.w700), ), ); } } class _ErrorPanel extends StatelessWidget { final String message; final VoidCallback onRetry; const _ErrorPanel({required this.message, required this.onRetry}); @override Widget build(BuildContext context) { return Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ const Icon(Icons.wifi_off, size: 48, color: AppColors.muted), const SizedBox(height: 12), Text(message, textAlign: TextAlign.center, style: const TextStyle(color: AppColors.muted)), const SizedBox(height: 16), FilledButton.icon( onPressed: onRetry, icon: const Icon(Icons.refresh), label: const Text('Coba lagi'), ), ], ), ); } } class _EmptyPanel extends StatelessWidget { const _EmptyPanel(); @override Widget build(BuildContext context) { return const Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ Icon(Icons.notifications_none, size: 64, color: AppColors.muted), SizedBox(height: 12), Text('Belum ada notifikasi', style: TextStyle( fontSize: 16, fontWeight: FontWeight.w600, color: AppColors.muted)), SizedBox(height: 4), Text('Guardian belum mengirim pesan.', style: TextStyle(color: AppColors.muted)), ], ), ); } }