Fix local dev config and ALOTTTT OFF FLUTTER, im tired boss..

This commit is contained in:
Wowieee4
2026-05-26 21:09:13 +07:00
parent b8ad8df993
commit a629357e8c
72 changed files with 2566 additions and 530 deletions
@@ -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(),
)),
);
}
}
@@ -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() ?? ''),
);
}
@@ -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,
});
}
@@ -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);
}
@@ -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),
@@ -0,0 +1 @@
export '../../notification_screen.dart';