// lib/features/notifications/notification_screen.dart // ignore_for_file: use_build_context_synchronously import 'dart:async'; import 'dart:convert'; import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:intl/intl.dart'; import 'package:just_audio/just_audio.dart'; import 'package:path_provider/path_provider.dart'; import '../../app/injection_container.dart'; import '../../core/errors/friendly_error.dart'; import '../../core/services/tts_service.dart'; import '../../core/theme/app_colors.dart'; import '../../core/theme/app_decorations.dart'; import '../../core/theme/app_text_styles.dart'; import '../../shared/widgets/animations/animations.dart'; import 'application/notification_cubit.dart'; import 'domain/entities/guardian_notification.dart'; class NotificationScreen extends StatefulWidget { const NotificationScreen({super.key}); @override State createState() => _NotificationScreenState(); } class _NotificationScreenState extends State { late final NotificationCubit _notificationCubit; final AudioPlayer _audioPlayer = AudioPlayer(); @override void initState() { super.initState(); _notificationCubit = sl(); _load(); } @override void dispose() { _audioPlayer.dispose(); _notificationCubit.close(); super.dispose(); } Future _load() async { await _notificationCubit.load(); } Future _markRead(int id) async { await runFriendlyAction( () async { await _notificationCubit.markOneRead(id); }, onError: (_) {}, fallback: 'Gagal menandai notifikasi.', ); } Future _markAllRead() async { await runFriendlyAction( () async { await _notificationCubit.markAllRead(); _snack('Semua notifikasi ditandai sudah dibaca.'); }, onError: _snack, fallback: 'Gagal menandai semua dibaca.', ); } Future _readAloud(_NotifItem notif) async { if (notif.type == 'VOICE_NOTE') { final source = notif.voiceNoteUrl == 'inline-audio' ? notif.content : notif.voiceNoteUrl; if (source == null || source.isEmpty) { _snack('Voice note kosong atau belum tersedia.'); return; } await _playVoiceNote(source); } else { final tts = sl(); tts.speak(notif.content ?? 'Pesan dari Guardian.'); } await _markRead(notif.id); } Future _playVoiceNote(String source) async { await runFriendlyAction( () async { String? localPath; if (source.startsWith('data:audio')) { final comma = source.indexOf(','); if (comma == -1) { throw const FormatException('Invalid inline audio payload'); } final bytes = base64Decode(source.substring(comma + 1)); final dir = await getTemporaryDirectory(); final file = File( '${dir.path}/walkguide_voice_${DateTime.now().millisecondsSinceEpoch}.m4a'); await file.writeAsBytes(bytes, flush: true); localPath = file.path; } if (localPath != null) { await _audioPlayer.setFilePath(localPath); } else { await _audioPlayer.setUrl(source); } await _audioPlayer.play(); }, onError: _snack, fallback: 'Voice note belum bisa diputar.', ); } void _snack(String msg) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(msg))); } } @override Widget build(BuildContext context) { return BlocBuilder( bloc: _notificationCubit, builder: (context, state) { final items = state.items.map(_NotifItem.fromEntity).toList(); final unreadCount = items.where((n) => !n.isRead).length; return DecoratedBox( decoration: const BoxDecoration( gradient: LinearGradient( colors: [AppColors.softBlueBg, Colors.white], begin: Alignment.topCenter, end: Alignment.bottomCenter, ), ), child: SafeArea( child: FadeSlideWrapper( 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: AppTextStyles.heading, ), 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: state.markingAll ? null : _markAllRead, icon: state.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: state.loading ? const Center(child: CircularProgressIndicator()) : state.error != null ? _ErrorPanel( message: state.error!, onRetry: _load) : items.isEmpty ? const _EmptyPanel() : RefreshIndicator( onRefresh: _load, child: ListView( children: [ StaggerWrapper( children: [ for (final item in items) Padding( padding: const EdgeInsets.only( bottom: 10), child: _NotifCard( notif: item, onMarkRead: () => _markRead(item.id), onReadAloud: () => _readAloud(item), ), ), ], ), ], ), ), ), ], ), ), ), ), ); }, ); } } // ─── 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.fromEntity(GuardianNotificationEntity entity) => _NotifItem( id: entity.id ?? 0, type: entity.notificationType, content: entity.content, voiceNoteUrl: entity.voiceNoteUrl, isRead: entity.isRead, createdAt: entity.createdAt ?? 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: AppDecorations.cardRadius, 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.08), blurRadius: 20, offset: const Offset(0, 4), ), ], ), 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(50), ), 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 (!isVoice && 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: Icon(isVoice ? Icons.play_arrow : Icons.volume_up, size: 16), label: Text(isVoice ? 'Putar' : 'Bacakan', style: const 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), boxShadow: AppDecorations.cardShadow, ), 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: Container( padding: const EdgeInsets.all(24), decoration: const BoxDecoration( color: AppColors.cardWhite, borderRadius: AppDecorations.cardRadius, boxShadow: AppDecorations.cardShadow, ), 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 Center( child: Container( padding: const EdgeInsets.all(24), decoration: const BoxDecoration( color: AppColors.cardWhite, borderRadius: AppDecorations.cardRadius, boxShadow: AppDecorations.cardShadow, ), child: const 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)), ], ), ), ); } }