564 lines
19 KiB
Dart
564 lines
19 KiB
Dart
import 'dart:async';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:firebase_database/firebase_database.dart';
|
|
import '../widgets/fade_in_up.dart';
|
|
import 'package:firebase_messaging/firebase_messaging.dart';
|
|
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
|
|
|
|
|
// ================= TOP LEVEL HANDLER =================
|
|
@pragma('vm:entry-point')
|
|
Future<void> _firebaseMessagingBackgroundHandler(RemoteMessage message) async {
|
|
print("Handling background message: ${message.messageId}");
|
|
}
|
|
|
|
final FlutterLocalNotificationsPlugin flutterLocalNotificationsPlugin =
|
|
FlutterLocalNotificationsPlugin();
|
|
|
|
// ================= MODEL =================
|
|
class NotificationModel {
|
|
final String id;
|
|
final String time;
|
|
final String date;
|
|
final double suhu;
|
|
final double alkohol;
|
|
final String status;
|
|
final String tipe;
|
|
final String jenisTape;
|
|
final String dbPath;
|
|
|
|
NotificationModel({
|
|
required this.id,
|
|
required this.time,
|
|
required this.date,
|
|
required this.suhu,
|
|
required this.alkohol,
|
|
required this.status,
|
|
required this.tipe,
|
|
required this.jenisTape,
|
|
required this.dbPath,
|
|
});
|
|
}
|
|
|
|
class NotificationPage extends StatefulWidget {
|
|
const NotificationPage({super.key});
|
|
|
|
@override
|
|
State<NotificationPage> createState() => _NotificationPageState();
|
|
}
|
|
|
|
class _NotificationPageState extends State<NotificationPage> {
|
|
final DatabaseReference notifRef = FirebaseDatabase.instance.ref("notifikasi");
|
|
final DatabaseReference warningRef = FirebaseDatabase.instance.ref("peringatan");
|
|
|
|
List<NotificationModel> notifications = [];
|
|
bool isLoading = true;
|
|
String selectedFilter = "Monitoring";
|
|
|
|
final Map<String, NotificationModel> _notifMap = {};
|
|
final Map<String, NotificationModel> _warningMap = {};
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
initLocalNotif();
|
|
initFCM();
|
|
listenNotification();
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
super.dispose();
|
|
}
|
|
|
|
void _deleteNotification(NotificationModel n) {
|
|
showDialog(
|
|
context: context,
|
|
builder: (context) => AlertDialog(
|
|
title: const Text("Hapus Pesan?"),
|
|
content: const Text("Apakah Anda yakin ingin menghapus pesan ini?"),
|
|
actions: [
|
|
TextButton(onPressed: () => Navigator.pop(context), child: const Text("Batal")),
|
|
ElevatedButton(
|
|
style: ElevatedButton.styleFrom(backgroundColor: Colors.red),
|
|
onPressed: () {
|
|
FirebaseDatabase.instance.ref(n.dbPath).child(n.id).remove();
|
|
Navigator.pop(context);
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
const SnackBar(content: Text("Pesan berhasil dihapus")),
|
|
);
|
|
},
|
|
child: const Text("Hapus", style: TextStyle(color: Colors.white)),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
void initLocalNotif() async {
|
|
const AndroidInitializationSettings androidSettings =
|
|
AndroidInitializationSettings('@mipmap/launcher_icon');
|
|
const InitializationSettings settings = InitializationSettings(
|
|
android: androidSettings,
|
|
);
|
|
await flutterLocalNotificationsPlugin.initialize(settings);
|
|
}
|
|
|
|
void initFCM() async {
|
|
FirebaseMessaging.onBackgroundMessage(_firebaseMessagingBackgroundHandler);
|
|
await FirebaseMessaging.instance.subscribeToTopic("fermentasi_update");
|
|
|
|
FirebaseMessaging.onMessage.listen((RemoteMessage message) {
|
|
if (message.notification != null) {
|
|
String body = message.notification!.body ?? "";
|
|
body = body
|
|
.replaceAll(RegExp(r'Proses Optimal', caseSensitive: false), 'Belum Matang')
|
|
.replaceAll(RegExp(r'Proses Awal', caseSensitive: false), 'Belum Matang')
|
|
.replaceAll(RegExp(r'Menjelang Matang', caseSensitive: false), 'Belum Matang');
|
|
|
|
flutterLocalNotificationsPlugin.show(
|
|
message.hashCode,
|
|
message.notification!.title,
|
|
body,
|
|
const NotificationDetails(
|
|
android: AndroidNotificationDetails(
|
|
'fermentasi_channel',
|
|
'Fermentasi Notifikasi',
|
|
importance: Importance.max,
|
|
priority: Priority.high,
|
|
icon: '@mipmap/launcher_icon',
|
|
),
|
|
),
|
|
);
|
|
}
|
|
});
|
|
}
|
|
|
|
NotificationModel? _parseNotification(String key, dynamic value, String sourcePath) {
|
|
try {
|
|
if (value is! Map) return null;
|
|
|
|
String id = key.toString();
|
|
DateTime notifDate = DateTime.now();
|
|
|
|
// Ekstrak waktu dari Firebase Push ID untuk akurasi tinggi
|
|
if (id.startsWith('-') && id.length >= 8) {
|
|
const chars = '-0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ_abcdefghijklmnopqrstuvwxyz';
|
|
int time = 0;
|
|
for (int i = 0; i < 8; i++) {
|
|
int index = chars.indexOf(id[i]);
|
|
if (index != -1) {
|
|
time = time * 64 + index;
|
|
}
|
|
}
|
|
if (time > 1000000000000) { // Validasi tahun (lebih dari 2001)
|
|
notifDate = DateTime.fromMillisecondsSinceEpoch(time);
|
|
}
|
|
}
|
|
|
|
String datePart = "${notifDate.day.toString().padLeft(2, '0')}-${notifDate.month.toString().padLeft(2, '0')}-${notifDate.year}";
|
|
String timePart = "${notifDate.hour.toString().padLeft(2, '0')}:${notifDate.minute.toString().padLeft(2, '0')}";
|
|
|
|
// Ekstrak tipe dengan sangat kokoh (mendukung key "tipe" / "type" dan status bahaya/warning/terlalu matang)
|
|
String rawTipe = (value["tipe"] ?? value["type"] ?? "").toString().toLowerCase().trim();
|
|
String rawStatus = (value["status"] ?? "").toString().toLowerCase().trim();
|
|
String tipeStr;
|
|
|
|
if (sourcePath == "peringatan") {
|
|
tipeStr = "Peringatan";
|
|
} else if (rawTipe == "selesai" || rawTipe == "done" || rawTipe == "finished" || rawTipe == "complete" || rawStatus == "matang" || rawStatus == "selesai") {
|
|
tipeStr = "Selesai";
|
|
} else if (rawTipe == "peringatan" || rawTipe == "warning" || rawTipe == "alert" || rawTipe == "danger" || rawTipe == "bahaya" ||
|
|
rawStatus.contains("peringatan") || rawStatus.contains("terlalu matang") || rawStatus.contains("bahaya") ||
|
|
rawStatus.contains("warning") || rawStatus.contains("alert") || rawStatus.contains("danger") || rawStatus.contains("tinggi")) {
|
|
tipeStr = "Peringatan";
|
|
} else {
|
|
tipeStr = "Monitoring";
|
|
}
|
|
|
|
if (tipeStr == "Selesai") {
|
|
String total = value["jam_total"]?.toString() ?? value["waktu"]?.toString() ?? "";
|
|
if (total.isNotEmpty && !total.contains("/")) {
|
|
if (total.toLowerCase().contains("jam")) {
|
|
timePart = total;
|
|
} else {
|
|
double? val = double.tryParse(total);
|
|
if (val != null) {
|
|
if (val == 0) {
|
|
timePart = "0 Detik";
|
|
} else if (val < 0.016) { // Kurang dari ~1 Menit
|
|
timePart = "${(val * 3600).toInt()} Detik";
|
|
} else if (val < 1.0) { // Kurang dari 1 Jam
|
|
timePart = "${(val * 60).toInt()} Menit";
|
|
} else {
|
|
timePart = "${val.toStringAsFixed(2)} Jam";
|
|
}
|
|
} else {
|
|
timePart = "$total Jam";
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
String statusStr = value["status"]?.toString() ?? (sourcePath == "peringatan" ? "Terlalu Matang" : "Monitoring");
|
|
String lowerStatus = statusStr.toLowerCase();
|
|
if (lowerStatus.contains("optimal") || lowerStatus.contains("awal") || lowerStatus.contains("menjelang matang")) {
|
|
statusStr = "Belum Matang";
|
|
}
|
|
|
|
if (tipeStr == "Peringatan" && !statusStr.toLowerCase().contains("terlalu matang")) {
|
|
return null;
|
|
}
|
|
|
|
return NotificationModel(
|
|
id: id,
|
|
time: timePart,
|
|
date: datePart,
|
|
suhu: (value["suhu"] as num?)?.toDouble() ?? 0.0,
|
|
alkohol: (value["alkohol"] as num?)?.toDouble() ?? 0.0,
|
|
status: statusStr,
|
|
tipe: tipeStr,
|
|
jenisTape: value["jenisTape"] ?? "Tape Ketan",
|
|
dbPath: sourcePath,
|
|
);
|
|
} catch (e) {
|
|
debugPrint("Error parsing single notification: $e");
|
|
return null;
|
|
}
|
|
}
|
|
|
|
void listenNotification() {
|
|
// 1. Listen to 'notifikasi' path
|
|
notifRef.onValue.listen((event) {
|
|
final data = event.snapshot.value as Map<dynamic, dynamic>?;
|
|
_notifMap.clear();
|
|
if (data != null) {
|
|
data.forEach((key, value) {
|
|
final model = _parseNotification(key.toString(), value, "notifikasi");
|
|
if (model != null) {
|
|
_notifMap[model.id] = model;
|
|
}
|
|
});
|
|
}
|
|
_combineAndSortNotifications();
|
|
});
|
|
|
|
// 2. Listen to 'peringatan' path
|
|
warningRef.onValue.listen((event) {
|
|
final data = event.snapshot.value as Map<dynamic, dynamic>?;
|
|
_warningMap.clear();
|
|
if (data != null) {
|
|
data.forEach((key, value) {
|
|
final model = _parseNotification(key.toString(), value, "peringatan");
|
|
if (model != null) {
|
|
_warningMap[model.id] = model;
|
|
}
|
|
});
|
|
}
|
|
_combineAndSortNotifications();
|
|
});
|
|
}
|
|
|
|
void _combineAndSortNotifications() {
|
|
List<NotificationModel> temp = [..._notifMap.values, ..._warningMap.values];
|
|
temp.sort((a, b) => b.id.compareTo(a.id)); // Reversed push-ID (newest first)
|
|
|
|
if (mounted) {
|
|
setState(() {
|
|
notifications = temp;
|
|
isLoading = false;
|
|
});
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Scaffold(
|
|
extendBody: true,
|
|
body: Stack(
|
|
children: [
|
|
Container(
|
|
decoration: const BoxDecoration(
|
|
gradient: LinearGradient(
|
|
begin: Alignment.topLeft,
|
|
end: Alignment.bottomRight,
|
|
stops: [0.0, 0.4, 0.7, 1.0],
|
|
colors: [
|
|
Color(0xFFE8F5E9),
|
|
Color(0xFFFFFDF0),
|
|
Color(0xFFF1F8E9),
|
|
Color(0xFFE8F5E9),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
Column(
|
|
children: [
|
|
FadeInUp(delay: 0, child: _buildAppBar()),
|
|
FadeInUp(delay: 150, child: _buildFilterTabs()),
|
|
Expanded(
|
|
child: isLoading
|
|
? const Center(child: CircularProgressIndicator())
|
|
: FadeInUp(delay: 300, child: _buildMessageList()),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
// ================= 1. APP BAR =================
|
|
Widget _buildAppBar() {
|
|
return SafeArea(
|
|
child: Padding(
|
|
padding: const EdgeInsets.fromLTRB(20, 24, 20, 5),
|
|
child: Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
children: [
|
|
IconButton(
|
|
icon: const Icon(Icons.arrow_back_ios_new, size: 20),
|
|
onPressed: () => Navigator.pop(context),
|
|
),
|
|
const Expanded(
|
|
child: Text(
|
|
"Fermentation Message",
|
|
textAlign: TextAlign.center,
|
|
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
),
|
|
const Icon(Icons.notifications_active, color: Color(0xFF2E7D32)),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
// ================= 2. FILTER TABS =================
|
|
Widget _buildFilterTabs() {
|
|
return Padding(
|
|
padding: const EdgeInsets.only(top: 18, bottom: 18),
|
|
child: Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
|
children: [
|
|
_filterBtn("Monitoring"),
|
|
_filterBtn("Peringatan"),
|
|
_filterBtn("Selesai"),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _filterBtn(String label) {
|
|
bool isSelected = selectedFilter == label;
|
|
return GestureDetector(
|
|
onTap: () => setState(() => selectedFilter = label),
|
|
child: Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
|
|
decoration: BoxDecoration(
|
|
color: isSelected ? const Color(0xFF2E7D32) : const Color(0xFFE8F5E9),
|
|
borderRadius: BorderRadius.circular(15),
|
|
gradient: isSelected
|
|
? const LinearGradient(
|
|
colors: [Color(0xFF2E7D32), Color(0xFF4CAF50)],
|
|
)
|
|
: null,
|
|
),
|
|
child: Text(
|
|
label,
|
|
style: TextStyle(
|
|
color: isSelected ? Colors.white : const Color(0xFF2E7D32),
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
// ================= 3. LIST PESAN =================
|
|
Widget _buildMessageList() {
|
|
final filteredList = notifications
|
|
.where((n) => n.tipe.toLowerCase() == selectedFilter.toLowerCase())
|
|
.toList();
|
|
|
|
if (filteredList.isEmpty) {
|
|
return const Center(child: Text("Belum ada pesan untuk kategori ini"));
|
|
}
|
|
|
|
return ListView.builder(
|
|
padding: const EdgeInsets.symmetric(horizontal: 20),
|
|
itemCount: filteredList.length,
|
|
itemBuilder: (context, index) {
|
|
final n = filteredList[index];
|
|
return _buildMessageCard(n);
|
|
},
|
|
);
|
|
}
|
|
|
|
Widget _buildMessageCard(NotificationModel n) {
|
|
bool isSelesai = n.tipe.toLowerCase() == "selesai";
|
|
bool isManual = n.tipe.toLowerCase() == "peringatan";
|
|
|
|
return Container(
|
|
margin: const EdgeInsets.only(bottom: 18),
|
|
padding: const EdgeInsets.all(20),
|
|
decoration: BoxDecoration(
|
|
color: Colors.white,
|
|
borderRadius: BorderRadius.circular(25),
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: Colors.black.withOpacity(0.04),
|
|
blurRadius: 15,
|
|
offset: const Offset(0, 5),
|
|
),
|
|
],
|
|
border: Border.all(color: Colors.grey.withOpacity(0.1)),
|
|
),
|
|
child: Stack(
|
|
children: [
|
|
Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
"${n.jenisTape} | ${n.tipe}",
|
|
style: const TextStyle(color: Colors.black45, fontSize: 13),
|
|
),
|
|
const SizedBox(height: 8),
|
|
if (isManual)
|
|
Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
"${n.jenisTape} Terlalu Matang",
|
|
style: const TextStyle(
|
|
fontSize: 18,
|
|
fontWeight: FontWeight.bold,
|
|
height: 1.2,
|
|
color: Colors.redAccent,
|
|
),
|
|
),
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
"Alkohol saat ini: ${n.jenisTape.contains('Singkong') ? n.alkohol.toStringAsFixed(1) : n.alkohol.toStringAsFixed(2)}%",
|
|
style: const TextStyle(
|
|
fontSize: 16,
|
|
fontWeight: FontWeight.w600,
|
|
color: Colors.black87,
|
|
),
|
|
),
|
|
],
|
|
)
|
|
else
|
|
Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
"Suhu: ${n.suhu}°C",
|
|
style: const TextStyle(
|
|
fontSize: 18,
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
Text(
|
|
"Alkohol: ${n.jenisTape.contains('Singkong') ? n.alkohol.toStringAsFixed(1) : n.alkohol.toStringAsFixed(2)}%",
|
|
style: const TextStyle(
|
|
fontSize: 18,
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
Text(
|
|
"Status: ${n.status}",
|
|
style: const TextStyle(
|
|
fontSize: 18,
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 12),
|
|
if (!isManual)
|
|
Row(
|
|
children: [
|
|
const Icon(
|
|
Icons.access_time_filled,
|
|
size: 16,
|
|
color: Color(0xFF81C784),
|
|
),
|
|
const SizedBox(width: 5),
|
|
Text(
|
|
n.time,
|
|
style: const TextStyle(
|
|
color: Color(0xFF81C784),
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
const SizedBox(width: 15),
|
|
const Icon(
|
|
Icons.calendar_month,
|
|
size: 16,
|
|
color: Color(0xFF81C784),
|
|
),
|
|
const SizedBox(width: 5),
|
|
Text(
|
|
n.date,
|
|
style: const TextStyle(
|
|
color: Color(0xFF81C784),
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
Positioned(
|
|
right: 0,
|
|
top: 0,
|
|
child: GestureDetector(
|
|
onTap: () => _deleteNotification(n),
|
|
child: Container(
|
|
padding: const EdgeInsets.all(8),
|
|
decoration: BoxDecoration(
|
|
color: Colors.red.withOpacity(0.1),
|
|
borderRadius: BorderRadius.circular(10),
|
|
),
|
|
child: const Icon(
|
|
Icons.delete_outline_rounded,
|
|
color: Colors.red,
|
|
size: 18,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
if (!isManual)
|
|
Positioned(
|
|
right: 0,
|
|
bottom: 0,
|
|
child: Container(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 10,
|
|
vertical: 4,
|
|
),
|
|
decoration: BoxDecoration(
|
|
color: isSelesai
|
|
? const Color(0xFFE8F5E9)
|
|
: const Color(0xFFFFFDE7),
|
|
borderRadius: BorderRadius.circular(8),
|
|
),
|
|
child: Text(
|
|
isSelesai ? "Done" : "In Progress",
|
|
style: TextStyle(
|
|
color: isSelesai ? const Color(0xFF2E7D32) : Colors.orange,
|
|
fontSize: 11,
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|