Fix local dev config and ALOTTTT OFF FLUTTER, im tired boss..
This commit is contained in:
+91
@@ -0,0 +1,91 @@
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
import '../domain/entities/guardian_notification.dart';
|
||||
import '../domain/repositories/notification_repository.dart';
|
||||
|
||||
class NotificationState {
|
||||
final bool loading;
|
||||
final List<GuardianNotificationEntity> items;
|
||||
final String? error;
|
||||
final bool markingAll;
|
||||
|
||||
const NotificationState({
|
||||
this.loading = false,
|
||||
this.items = const [],
|
||||
this.error,
|
||||
this.markingAll = false,
|
||||
});
|
||||
|
||||
NotificationState copyWith({
|
||||
bool? loading,
|
||||
List<GuardianNotificationEntity>? items,
|
||||
String? error,
|
||||
bool? markingAll,
|
||||
}) {
|
||||
return NotificationState(
|
||||
loading: loading ?? this.loading,
|
||||
items: items ?? this.items,
|
||||
error: error,
|
||||
markingAll: markingAll ?? this.markingAll,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class NotificationCubit extends Cubit<NotificationState> {
|
||||
final NotificationRepository _repository;
|
||||
|
||||
NotificationCubit(this._repository) : super(const NotificationState());
|
||||
|
||||
Future<void> load() async {
|
||||
emit(const NotificationState(loading: true));
|
||||
final result = await _repository.getNotifications();
|
||||
result.fold(
|
||||
(failure) => emit(NotificationState(error: failure.message)),
|
||||
(items) => emit(NotificationState(items: items)),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> markOneRead(int id) async {
|
||||
final result = await _repository.markOneRead(id);
|
||||
result.fold(
|
||||
(failure) => emit(state.copyWith(error: failure.message)),
|
||||
(_) => emit(state.copyWith(
|
||||
items: state.items
|
||||
.map((item) => item.id == id
|
||||
? GuardianNotificationEntity(
|
||||
id: item.id,
|
||||
notificationType: item.notificationType,
|
||||
content: item.content,
|
||||
voiceNoteUrl: item.voiceNoteUrl,
|
||||
voiceNoteDuration: item.voiceNoteDuration,
|
||||
isRead: true,
|
||||
createdAt: item.createdAt,
|
||||
)
|
||||
: item)
|
||||
.toList(),
|
||||
)),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> markAllRead() async {
|
||||
emit(state.copyWith(markingAll: true));
|
||||
final result = await _repository.markAllRead();
|
||||
result.fold(
|
||||
(failure) => emit(state.copyWith(error: failure.message, markingAll: false)),
|
||||
(_) => emit(state.copyWith(
|
||||
markingAll: false,
|
||||
items: state.items
|
||||
.map((item) => GuardianNotificationEntity(
|
||||
id: item.id,
|
||||
notificationType: item.notificationType,
|
||||
content: item.content,
|
||||
voiceNoteUrl: item.voiceNoteUrl,
|
||||
voiceNoteDuration: item.voiceNoteDuration,
|
||||
isRead: true,
|
||||
createdAt: item.createdAt,
|
||||
))
|
||||
.toList(),
|
||||
)),
|
||||
);
|
||||
}
|
||||
}
|
||||
+90
@@ -0,0 +1,90 @@
|
||||
import 'package:dartz/dartz.dart';
|
||||
|
||||
import '../../../../core/errors/failures.dart';
|
||||
import '../../../../core/network/api_client.dart';
|
||||
import '../../../../core/storage/local_database.dart';
|
||||
import '../../domain/entities/guardian_notification.dart';
|
||||
import '../../domain/repositories/notification_repository.dart';
|
||||
|
||||
class NotificationRepositoryImpl implements NotificationRepository {
|
||||
final ApiClient _apiClient;
|
||||
final LocalDatabase _database;
|
||||
|
||||
const NotificationRepositoryImpl(this._apiClient, this._database);
|
||||
|
||||
@override
|
||||
Future<Either<Failure, List<GuardianNotificationEntity>>> getNotifications() async {
|
||||
try {
|
||||
final response = await _apiClient.dio.get('/user/notifications');
|
||||
final body = response.data;
|
||||
final data = body is Map ? body['data'] : body;
|
||||
final rawList = data is Map ? data['content'] : data;
|
||||
final list = rawList is List
|
||||
? rawList
|
||||
.whereType<Map>()
|
||||
.map((item) => _fromJson(Map<String, dynamic>.from(item)))
|
||||
.toList()
|
||||
: <GuardianNotificationEntity>[];
|
||||
await _database.notifications.replaceAll(
|
||||
list
|
||||
.map((item) => CachedNotification(
|
||||
id: item.id,
|
||||
notificationType: item.notificationType,
|
||||
content: item.content,
|
||||
voiceNoteUrl: item.voiceNoteUrl,
|
||||
isRead: item.isRead,
|
||||
createdAt: item.createdAt ?? DateTime.now(),
|
||||
))
|
||||
.toList(),
|
||||
);
|
||||
return Right(list);
|
||||
} catch (_) {
|
||||
final cached = await _database.notifications.getAll();
|
||||
return Right(
|
||||
cached
|
||||
.map((item) => GuardianNotificationEntity(
|
||||
id: item.id,
|
||||
notificationType: item.notificationType,
|
||||
content: item.content,
|
||||
voiceNoteUrl: item.voiceNoteUrl,
|
||||
isRead: item.isRead,
|
||||
createdAt: item.createdAt,
|
||||
))
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Either<Failure, void>> markAllRead() async {
|
||||
try {
|
||||
await _apiClient.dio.put('/user/notifications/mark-all-read');
|
||||
return const Right(null);
|
||||
} catch (_) {
|
||||
return const Left(NetworkFailure('Gagal menandai semua notifikasi.'));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Either<Failure, void>> markOneRead(int id) async {
|
||||
try {
|
||||
await _apiClient.dio.put('/user/notifications/$id/read');
|
||||
return const Right(null);
|
||||
} catch (_) {
|
||||
return const Left(NetworkFailure('Gagal menandai notifikasi.'));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
GuardianNotificationEntity _fromJson(Map<String, dynamic> json) {
|
||||
return GuardianNotificationEntity(
|
||||
id: (json['id'] as num?)?.toInt(),
|
||||
notificationType:
|
||||
json['notifType']?.toString() ?? json['notificationType']?.toString() ?? 'TEXT',
|
||||
content: json['content']?.toString(),
|
||||
voiceNoteUrl: json['voiceNoteUrl']?.toString(),
|
||||
voiceNoteDuration: (json['voiceNoteDuration'] as num?)?.toInt(),
|
||||
isRead: json['isRead'] == true,
|
||||
createdAt: DateTime.tryParse(json['createdAt']?.toString() ?? ''),
|
||||
);
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
class GuardianNotificationEntity {
|
||||
final int? id;
|
||||
final String notificationType;
|
||||
final String? content;
|
||||
final String? voiceNoteUrl;
|
||||
final int? voiceNoteDuration;
|
||||
final bool isRead;
|
||||
final DateTime? createdAt;
|
||||
|
||||
const GuardianNotificationEntity({
|
||||
this.id,
|
||||
required this.notificationType,
|
||||
this.content,
|
||||
this.voiceNoteUrl,
|
||||
this.voiceNoteDuration,
|
||||
this.isRead = false,
|
||||
this.createdAt,
|
||||
});
|
||||
}
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
import 'package:dartz/dartz.dart';
|
||||
|
||||
import '../../../../core/errors/failures.dart';
|
||||
import '../entities/guardian_notification.dart';
|
||||
|
||||
abstract class NotificationRepository {
|
||||
Future<Either<Failure, List<GuardianNotificationEntity>>> getNotifications();
|
||||
Future<Either<Failure, void>> markAllRead();
|
||||
Future<Either<Failure, void>> markOneRead(int id);
|
||||
}
|
||||
+103
-86
@@ -2,16 +2,19 @@
|
||||
// ignore_for_file: use_build_context_synchronously
|
||||
|
||||
import 'dart:async';
|
||||
import 'package:dio/dio.dart';
|
||||
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/network/api_client.dart';
|
||||
import '../../core/services/tts_service.dart';
|
||||
import '../../core/theme/app_colors.dart';
|
||||
|
||||
Dio get _api => sl<ApiClient>().dio;
|
||||
import 'application/notification_cubit.dart';
|
||||
import 'domain/entities/guardian_notification.dart';
|
||||
|
||||
class NotificationScreen extends StatefulWidget {
|
||||
const NotificationScreen({super.key});
|
||||
@@ -21,58 +24,31 @@ class NotificationScreen extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _NotificationScreenState extends State<NotificationScreen> {
|
||||
List<_NotifItem> _items = [];
|
||||
bool _loading = true;
|
||||
String? _error;
|
||||
bool _markingAll = false;
|
||||
late final NotificationCubit _notificationCubit;
|
||||
final AudioPlayer _audioPlayer = AudioPlayer();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_notificationCubit = sl<NotificationCubit>();
|
||||
_load();
|
||||
}
|
||||
|
||||
Future<void> _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);
|
||||
@override
|
||||
void dispose() {
|
||||
_audioPlayer.dispose();
|
||||
_notificationCubit.close();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
List<Map<String, dynamic>> _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>()
|
||||
.map((item) => Map<String, dynamic>.from(item))
|
||||
.toList();
|
||||
Future<void> _load() async {
|
||||
await _notificationCubit.load();
|
||||
}
|
||||
|
||||
Future<void> _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);
|
||||
});
|
||||
await _notificationCubit.markOneRead(id);
|
||||
},
|
||||
onError: (_) {},
|
||||
fallback: 'Gagal menandai notifikasi.',
|
||||
@@ -80,45 +56,80 @@ class _NotificationScreenState extends State<NotificationScreen> {
|
||||
}
|
||||
|
||||
Future<void> _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();
|
||||
});
|
||||
await _notificationCubit.markAllRead();
|
||||
_snack('Semua notifikasi ditandai sudah dibaca.');
|
||||
},
|
||||
onError: _snack,
|
||||
fallback: 'Gagal menandai semua dibaca.',
|
||||
);
|
||||
if (mounted) setState(() => _markingAll = false);
|
||||
}
|
||||
|
||||
Future<void> _readAloud(_NotifItem notif) async {
|
||||
final tts = sl<TtsService>();
|
||||
tts.speak(notif.content ?? 'Voice note dari Guardian.');
|
||||
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<TtsService>();
|
||||
tts.speak(notif.content ?? 'Pesan dari Guardian.');
|
||||
}
|
||||
await _markRead(notif.id);
|
||||
}
|
||||
|
||||
Future<void> _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)));
|
||||
}
|
||||
}
|
||||
|
||||
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: [
|
||||
return BlocBuilder<NotificationCubit, NotificationState>(
|
||||
bloc: _notificationCubit,
|
||||
builder: (context, state) {
|
||||
final items = state.items.map(_NotifItem.fromEntity).toList();
|
||||
final unreadCount = items.where((n) => !n.isRead).length;
|
||||
return SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Header
|
||||
Row(
|
||||
children: [
|
||||
@@ -135,9 +146,9 @@ class _NotificationScreenState extends State<NotificationScreen> {
|
||||
.headlineSmall
|
||||
?.copyWith(fontWeight: FontWeight.w800),
|
||||
),
|
||||
if (_unreadCount > 0) ...[
|
||||
if (unreadCount > 0) ...[
|
||||
const SizedBox(width: 8),
|
||||
_UnreadBadge(count: _unreadCount),
|
||||
_UnreadBadge(count: unreadCount),
|
||||
],
|
||||
],
|
||||
),
|
||||
@@ -148,10 +159,10 @@ class _NotificationScreenState extends State<NotificationScreen> {
|
||||
],
|
||||
),
|
||||
),
|
||||
if (_unreadCount > 0)
|
||||
if (unreadCount > 0)
|
||||
TextButton.icon(
|
||||
onPressed: _markingAll ? null : _markAllRead,
|
||||
icon: _markingAll
|
||||
onPressed: state.markingAll ? null : _markAllRead,
|
||||
icon: state.markingAll
|
||||
? const SizedBox(
|
||||
width: 14,
|
||||
height: 14,
|
||||
@@ -170,29 +181,31 @@ class _NotificationScreenState extends State<NotificationScreen> {
|
||||
|
||||
// Body
|
||||
Expanded(
|
||||
child: _loading
|
||||
child: state.loading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: _error != null
|
||||
? _ErrorPanel(message: _error!, onRetry: _load)
|
||||
: _items.isEmpty
|
||||
: state.error != null
|
||||
? _ErrorPanel(message: state.error!, onRetry: _load)
|
||||
: items.isEmpty
|
||||
? const _EmptyPanel()
|
||||
: RefreshIndicator(
|
||||
onRefresh: _load,
|
||||
child: ListView.separated(
|
||||
itemCount: _items.length,
|
||||
itemCount: items.length,
|
||||
separatorBuilder: (_, __) =>
|
||||
const SizedBox(height: 10),
|
||||
itemBuilder: (ctx, i) => _NotifCard(
|
||||
notif: _items[i],
|
||||
onMarkRead: () => _markRead(_items[i].id),
|
||||
onReadAloud: () => _readAloud(_items[i]),
|
||||
notif: items[i],
|
||||
onMarkRead: () => _markRead(items[i].id),
|
||||
onReadAloud: () => _readAloud(items[i]),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -216,14 +229,14 @@ class _NotifItem {
|
||||
required this.createdAt,
|
||||
});
|
||||
|
||||
factory _NotifItem.fromJson(Map<String, dynamic> 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(),
|
||||
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(
|
||||
@@ -329,7 +342,9 @@ class _NotifCard extends StatelessWidget {
|
||||
),
|
||||
],
|
||||
),
|
||||
if (notif.content != null && notif.content!.isNotEmpty) ...[
|
||||
if (!isVoice &&
|
||||
notif.content != null &&
|
||||
notif.content!.isNotEmpty) ...[
|
||||
const SizedBox(height: 10),
|
||||
Text(
|
||||
notif.content!,
|
||||
@@ -342,8 +357,10 @@ class _NotifCard extends StatelessWidget {
|
||||
// Read aloud button
|
||||
OutlinedButton.icon(
|
||||
onPressed: onReadAloud,
|
||||
icon: const Icon(Icons.volume_up, size: 16),
|
||||
label: const Text('Bacakan', style: TextStyle(fontSize: 13)),
|
||||
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),
|
||||
|
||||
+1
@@ -0,0 +1 @@
|
||||
export '../../notification_screen.dart';
|
||||
Reference in New Issue
Block a user