diff --git a/walkguide-mobile/walkguide_app/lib/features/notifications/notification_screen.dart b/walkguide-mobile/walkguide_app/lib/features/notifications/notification_screen.dart index 361d391..9ef05e7 100644 --- a/walkguide-mobile/walkguide_app/lib/features/notifications/notification_screen.dart +++ b/walkguide-mobile/walkguide_app/lib/features/notifications/notification_screen.dart @@ -1 +1,453 @@ -export '../screens.dart' show NotificationScreen; +// 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:go_router/go_router.dart'; +import 'package:intl/intl.dart'; +import '../../app/injection_container.dart'; +import '../../core/constants/app_constants.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; + }); + try { + final res = await _api + .get('/user/notifications') + .timeout(const Duration(seconds: 10)); + final list = (res.data['data'] as List?) ?? []; + setState(() { + _items = list.map((e) => _NotifItem.fromJson(e)).toList(); + }); + } on DioException catch (e) { + setState(() { + _error = e.response?.data?['message']?.toString() ?? + 'Gagal memuat notifikasi.'; + }); + } catch (e) { + setState(() => _error = 'Timeout / error: $e'); + } finally { + if (mounted) setState(() => _loading = false); + } + } + + Future _markRead(int id) async { + try { + await _api + .patch('/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); + }); + } catch (_) {} + } + + Future _markAllRead() async { + setState(() => _markingAll = true); + try { + await _api + .patch('/user/notifications/read-all') + .timeout(const Duration(seconds: 8)); + setState(() { + _items = _items.map((n) => n.copyWith(isRead: true)).toList(); + }); + _snack('Semua notifikasi ditandai sudah dibaca.'); + } on DioException catch (e) { + _snack(e.response?.data?['message']?.toString() ?? + 'Gagal menandai semua dibaca.'); + } catch (_) { + _snack('Timeout. Coba lagi.'); + } finally { + 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.withOpacity(0.3) + : const Color(0xFFE2E8F0), + width: unread ? 1.5 : 1, + ), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(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.withOpacity(0.12) + : AppColors.primary.withOpacity(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)), + ], + ), + ); + } +}