first commit
|
|
@ -1,3 +1,4 @@
|
|||
{
|
||||
"java.configuration.updateBuildConfiguration": "automatic"
|
||||
"java.configuration.updateBuildConfiguration": "automatic",
|
||||
"cmake.sourceDirectory": "E:/JOKI/smartinfuse/linux"
|
||||
}
|
||||
|
|
@ -1,6 +1,14 @@
|
|||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<!-- Permissions untuk notifications -->
|
||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
|
||||
<uses-permission android:name="android.permission.VIBRATE"/>
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK"/>
|
||||
<uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT"/>
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
||||
|
||||
<application
|
||||
android:label="smartinfuse"
|
||||
android:label="SMART INFUSE"
|
||||
android:name="${applicationName}"
|
||||
android:icon="@mipmap/ic_launcher">
|
||||
<activity
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 544 B |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
Before Width: | Height: | Size: 442 B |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 721 B |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 4.8 KiB |
|
Before Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 4.8 KiB |
|
After Width: | Height: | Size: 7.5 KiB |
|
Before Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 7.5 KiB |
|
After Width: | Height: | Size: 9.4 KiB |
|
|
@ -1,14 +1,21 @@
|
|||
class ChartDataPoint {
|
||||
final int hour;
|
||||
final DateTime timestamp;
|
||||
final double dropsPerMinute;
|
||||
|
||||
ChartDataPoint({
|
||||
required this.hour,
|
||||
required this.timestamp,
|
||||
required this.dropsPerMinute,
|
||||
});
|
||||
|
||||
/// Label waktu format HH:mm, dipakai di sumbu X grafik & tooltip.
|
||||
String get timeLabel {
|
||||
final h = timestamp.hour.toString().padLeft(2, '0');
|
||||
final m = timestamp.minute.toString().padLeft(2, '0');
|
||||
return '$h:$m';
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'ChartDataPoint(hour: $hour, dropsPerMinute: $dropsPerMinute)';
|
||||
return 'ChartDataPoint(timestamp: $timestamp, dropsPerMinute: $dropsPerMinute)';
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,3 @@
|
|||
// lib/app/models/realtime_monitoring_model.dart
|
||||
|
||||
class RealtimeMonitoringModel {
|
||||
final String deviceId;
|
||||
final String roomId;
|
||||
|
|
@ -10,6 +8,7 @@ class RealtimeMonitoringModel {
|
|||
final DateTime lastUpdate;
|
||||
final int servoAngleOpen;
|
||||
final int servoAngleClose;
|
||||
final bool buzzerActive; // ← tambah ini
|
||||
|
||||
RealtimeMonitoringModel({
|
||||
required this.deviceId,
|
||||
|
|
@ -21,9 +20,9 @@ class RealtimeMonitoringModel {
|
|||
required this.lastUpdate,
|
||||
this.servoAngleOpen = 90,
|
||||
this.servoAngleClose = 0,
|
||||
this.buzzerActive = false, // ← tambah ini
|
||||
});
|
||||
|
||||
// From Realtime Database
|
||||
factory RealtimeMonitoringModel.fromRealtimeDB(
|
||||
String deviceId,
|
||||
String roomId,
|
||||
|
|
@ -37,6 +36,7 @@ class RealtimeMonitoringModel {
|
|||
deviceId: deviceId,
|
||||
roomId: roomId,
|
||||
servoOpen: controlling['servo_open'] ?? false,
|
||||
buzzerActive: controlling['buzzer_active'] ?? false, // ← tambah ini
|
||||
lastCommand: DateTime.parse(
|
||||
controlling['last_command'] ?? DateTime.now().toIso8601String(),
|
||||
),
|
||||
|
|
@ -50,11 +50,11 @@ class RealtimeMonitoringModel {
|
|||
);
|
||||
}
|
||||
|
||||
// To Realtime Database
|
||||
Map<String, dynamic> toRealtimeDB() {
|
||||
return {
|
||||
'controlling': {
|
||||
'servo_open': servoOpen,
|
||||
'buzzer_active': buzzerActive, // ← tambah ini
|
||||
'last_command': lastCommand.toIso8601String(),
|
||||
},
|
||||
'monitoring': {
|
||||
|
|
@ -73,6 +73,7 @@ class RealtimeMonitoringModel {
|
|||
String? deviceId,
|
||||
String? roomId,
|
||||
bool? servoOpen,
|
||||
bool? buzzerActive, // ← tambah ini
|
||||
DateTime? lastCommand,
|
||||
String? deviceStatus,
|
||||
double? dropRate,
|
||||
|
|
@ -84,6 +85,7 @@ class RealtimeMonitoringModel {
|
|||
deviceId: deviceId ?? this.deviceId,
|
||||
roomId: roomId ?? this.roomId,
|
||||
servoOpen: servoOpen ?? this.servoOpen,
|
||||
buzzerActive: buzzerActive ?? this.buzzerActive, // ← tambah ini
|
||||
lastCommand: lastCommand ?? this.lastCommand,
|
||||
deviceStatus: deviceStatus ?? this.deviceStatus,
|
||||
dropRate: dropRate ?? this.dropRate,
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ class DeviceDetailController extends GetxController {
|
|||
final isServoActive = false.obs;
|
||||
final deviceStatus = 'disconnected'.obs;
|
||||
final isLoading = true.obs;
|
||||
|
||||
final isBuzzerActive = false.obs;
|
||||
final RxList<ChartDataPoint> chartData = <ChartDataPoint>[].obs;
|
||||
|
||||
StreamSubscription? _realtimeSubscription;
|
||||
|
|
@ -117,6 +117,7 @@ class DeviceDetailController extends GetxController {
|
|||
lastUpdate.value = data.lastUpdate;
|
||||
isServoActive.value = data.servoOpen;
|
||||
deviceStatus.value = data.deviceStatus;
|
||||
isBuzzerActive.value = data.buzzerActive;
|
||||
}
|
||||
}, onError: (error) => AppSnackbar.error('Koneksi realtime terputus'));
|
||||
}
|
||||
|
|
@ -140,15 +141,16 @@ class DeviceDetailController extends GetxController {
|
|||
}
|
||||
|
||||
void _processHistoryData(List<HistoryModel> histories) {
|
||||
// Ambil 10 data TERBARU
|
||||
histories.sort((a, b) => b.timestamp.compareTo(a.timestamp));
|
||||
|
||||
final latestHistories = histories.take(10).toList();
|
||||
|
||||
// Urutkan lagi dari lama -> baru supaya grafik terbaca kiri ke kanan
|
||||
latestHistories.sort((a, b) => a.timestamp.compareTo(b.timestamp));
|
||||
|
||||
final List<ChartDataPoint> points = latestHistories.map((history) {
|
||||
return ChartDataPoint(
|
||||
hour: history.timestamp.hour,
|
||||
timestamp: history.timestamp,
|
||||
dropsPerMinute: history.dropPerMinute,
|
||||
);
|
||||
}).toList();
|
||||
|
|
@ -186,7 +188,18 @@ class DeviceDetailController extends GetxController {
|
|||
);
|
||||
}
|
||||
|
||||
Future<void> turnOffBuzzer() async {
|
||||
try {
|
||||
final dbRoomId = roomId!.toLowerCase().replaceAll(' ', '_');
|
||||
await _realtimeService.controlBuzzer(dbRoomId, deviceId!, false);
|
||||
isBuzzerActive.value = false;
|
||||
AppSnackbar.success('Buzzer dimatikan');
|
||||
} catch (e) {
|
||||
AppSnackbar.error('Gagal mematikan buzzer');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> refreshData() async {
|
||||
await _loadDeviceData();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -49,15 +49,24 @@ class DeviceDetailView extends GetView<DeviceDetailController> {
|
|||
children: [
|
||||
_buildSection(
|
||||
title: 'Monitoring Infus',
|
||||
onSeeAll: () {},
|
||||
child: _buildMonitoringCard(),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
_buildSection(
|
||||
title: 'Kontrol Alat',
|
||||
onSeeAll: () {},
|
||||
child: _buildControlCard(),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
// Buzzer card — hanya tampil kalau buzzer aktif
|
||||
Obx(() {
|
||||
if (!controller.isBuzzerActive.value) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
child: _buildBuzzerCard(),
|
||||
);
|
||||
}),
|
||||
const SizedBox(height: 20),
|
||||
_buildSection(
|
||||
title: 'History Data',
|
||||
|
|
@ -100,19 +109,7 @@ class DeviceDetailView extends GetView<DeviceDetailController> {
|
|||
fontWeight: FontWeight.bold,
|
||||
color: Colors.black87,
|
||||
),
|
||||
),
|
||||
if (onSeeAll != null)
|
||||
GestureDetector(
|
||||
onTap: onSeeAll,
|
||||
child: const Text(
|
||||
'See all',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: Color(0xFF2196F3),
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
@ -335,6 +332,98 @@ class DeviceDetailView extends GetView<DeviceDetailController> {
|
|||
);
|
||||
}
|
||||
|
||||
/// Card buzzer — muncul saat buzzer_active == true
|
||||
Widget _buildBuzzerCard() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.red[50],
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: Colors.red[200]!),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.red.withOpacity(0.08),
|
||||
blurRadius: 12,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
// Icon dengan animasi pulse
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.red[100],
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Icon(
|
||||
Icons.notifications_active_rounded,
|
||||
size: 28,
|
||||
color: Colors.red[700],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Buzzer Aktif',
|
||||
style: TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: Colors.red[800],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Tetesan infus berhenti. Harap periksa pasien.',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.red[600],
|
||||
height: 1.4,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
// Tombol matikan buzzer
|
||||
ElevatedButton(
|
||||
onPressed: controller.turnOffBuzzer,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.red[700],
|
||||
foregroundColor: Colors.white,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 14,
|
||||
vertical: 10,
|
||||
),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
elevation: 0,
|
||||
),
|
||||
child: const Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.volume_off_rounded, size: 18),
|
||||
SizedBox(height: 3),
|
||||
Text(
|
||||
'Matikan',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildEmptyChart() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(32),
|
||||
|
|
@ -375,4 +464,4 @@ class DeviceDetailView extends GetView<DeviceDetailController> {
|
|||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -5,6 +5,7 @@ import 'package:url_launcher/url_launcher.dart';
|
|||
import 'dart:async';
|
||||
import '../../../services/firestore_service.dart';
|
||||
import '../../../services/realtime_database_service.dart';
|
||||
import '../../../services/notification_service.dart';
|
||||
import '../../../models/patient_model.dart';
|
||||
import '../../../models/chart_data_point.dart';
|
||||
import '../../../models/history_model.dart';
|
||||
|
|
@ -51,6 +52,7 @@ class NotificationData {
|
|||
class HomePatientController extends GetxController {
|
||||
final FirestoreService _firestoreService = FirestoreService();
|
||||
final RealtimeDatabaseService _realtimeService = RealtimeDatabaseService();
|
||||
final NotificationService _notificationService = NotificationService();
|
||||
|
||||
final deviceId = ''.obs;
|
||||
final roomId = ''.obs;
|
||||
|
|
@ -71,6 +73,8 @@ class HomePatientController extends GetxController {
|
|||
deviceStatus: 'disconnected',
|
||||
).obs;
|
||||
|
||||
final isBuzzerActive = false.obs;
|
||||
|
||||
final chartDataPoints = <ChartDataPoint>[].obs;
|
||||
final notificationsList = <NotificationData>[].obs;
|
||||
|
||||
|
|
@ -82,9 +86,12 @@ class HomePatientController extends GetxController {
|
|||
StreamSubscription? _notificationsSubscription;
|
||||
StreamSubscription? _historySubscription;
|
||||
|
||||
final Set<String> _alertedDevices = {};
|
||||
|
||||
@override
|
||||
void onInit() {
|
||||
super.onInit();
|
||||
_initializeNotificationService();
|
||||
_loadDataFromArguments();
|
||||
}
|
||||
|
||||
|
|
@ -101,6 +108,15 @@ class HomePatientController extends GetxController {
|
|||
super.onClose();
|
||||
}
|
||||
|
||||
Future<void> _initializeNotificationService() async {
|
||||
try {
|
||||
await _notificationService.initialize();
|
||||
print('Notification service initialized');
|
||||
} catch (e) {
|
||||
print('Error initializing notification service: $e');
|
||||
}
|
||||
}
|
||||
|
||||
void _loadDataFromArguments() async {
|
||||
try {
|
||||
final args = Get.arguments as Map<String, dynamic>?;
|
||||
|
|
@ -176,14 +192,16 @@ class HomePatientController extends GetxController {
|
|||
|
||||
final dbRoomId = roomId.value.toLowerCase().replaceAll(' ', '_');
|
||||
|
||||
_realtimeSubscription?.cancel(); // Cancel previous subscription if exists
|
||||
_realtimeSubscription?.cancel();
|
||||
|
||||
_realtimeSubscription = _realtimeService
|
||||
.getDeviceStream(dbRoomId, deviceId.value)
|
||||
.listen(
|
||||
(device) {
|
||||
(device) async {
|
||||
if (device != null) {
|
||||
_updateCurrentInfusData(device);
|
||||
await _updateCurrentInfusData(device);
|
||||
await _checkDropRate(device);
|
||||
isBuzzerActive.value = device.buzzerActive;
|
||||
} else {
|
||||
print('Received null device data');
|
||||
}
|
||||
|
|
@ -200,7 +218,7 @@ class HomePatientController extends GetxController {
|
|||
}
|
||||
}
|
||||
|
||||
void _updateCurrentInfusData(RealtimeMonitoringModel device) async {
|
||||
Future<void> _updateCurrentInfusData(RealtimeMonitoringModel device) async {
|
||||
try {
|
||||
final room = await _firestoreService.getRoomById(device.roomId);
|
||||
|
||||
|
|
@ -217,11 +235,61 @@ class HomePatientController extends GetxController {
|
|||
}
|
||||
}
|
||||
|
||||
Future<void> _checkDropRate(RealtimeMonitoringModel device) async {
|
||||
try {
|
||||
final deviceId = device.deviceId;
|
||||
final dropRate = device.dropRate;
|
||||
final deviceStatus = device.deviceStatus;
|
||||
|
||||
if (dropRate == 0.0 && deviceStatus == 'connected') {
|
||||
if (!_alertedDevices.contains(deviceId)) {
|
||||
final patient = await _firestoreService.getPatientByDeviceId(
|
||||
deviceId,
|
||||
);
|
||||
final room = await _firestoreService.getRoomById(device.roomId);
|
||||
|
||||
if (patient != null) {
|
||||
await _notificationService.showDropRateAlertForPatient(
|
||||
patientId: patient.id,
|
||||
patientName: patient.namePatient,
|
||||
deviceId: deviceId,
|
||||
roomName: room?.roomName ?? device.roomId,
|
||||
);
|
||||
|
||||
_alertedDevices.add(deviceId);
|
||||
print('Alert sent for device: $deviceId');
|
||||
}
|
||||
}
|
||||
} else if (dropRate > 0.0) {
|
||||
if (_alertedDevices.contains(deviceId)) {
|
||||
_alertedDevices.remove(deviceId);
|
||||
_notificationService.clearCooldown(deviceId);
|
||||
await _notificationService.cancelNotificationByDeviceId(deviceId);
|
||||
print('Device reset and notification dismissed: $deviceId');
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
print('Error checking drop rate: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> turnOffBuzzer() async {
|
||||
try {
|
||||
final dbRoomId = roomId.value.toLowerCase().replaceAll(' ', '_');
|
||||
await _realtimeService.controlBuzzer(dbRoomId, deviceId.value, false);
|
||||
isBuzzerActive.value = false;
|
||||
AppSnackbar.success('Buzzer dimatikan');
|
||||
} catch (e) {
|
||||
print('Error turning off buzzer: $e');
|
||||
AppSnackbar.error('Gagal mematikan buzzer');
|
||||
}
|
||||
}
|
||||
|
||||
void _loadHistoryData() {
|
||||
try {
|
||||
isLoadingHistory.value = true;
|
||||
|
||||
_historySubscription?.cancel(); // Cancel previous subscription if exists
|
||||
_historySubscription?.cancel();
|
||||
|
||||
_historySubscription = _firestoreService
|
||||
.getDeviceHistoriesStream(deviceId.value)
|
||||
|
|
@ -257,12 +325,16 @@ class HomePatientController extends GetxController {
|
|||
|
||||
void _processHistoryData(List<HistoryModel> histories) {
|
||||
try {
|
||||
// Ambil 10 data TERBARU
|
||||
histories.sort((a, b) => b.timestamp.compareTo(a.timestamp));
|
||||
final latestHistories = histories.take(10).toList();
|
||||
|
||||
// Urutkan lagi dari lama -> baru supaya grafik terbaca kiri ke kanan
|
||||
latestHistories.sort((a, b) => a.timestamp.compareTo(b.timestamp));
|
||||
|
||||
final List<ChartDataPoint> points = latestHistories.map((history) {
|
||||
return ChartDataPoint(
|
||||
hour: history.timestamp.hour,
|
||||
timestamp: history.timestamp,
|
||||
dropsPerMinute: history.dropPerMinute,
|
||||
);
|
||||
}).toList();
|
||||
|
|
@ -278,8 +350,7 @@ class HomePatientController extends GetxController {
|
|||
try {
|
||||
isLoadingNotifications.value = true;
|
||||
|
||||
_notificationsSubscription
|
||||
?.cancel(); // Cancel previous subscription if exists
|
||||
_notificationsSubscription?.cancel();
|
||||
|
||||
_notificationsSubscription = _firestoreService
|
||||
.getNotificationsStream()
|
||||
|
|
@ -313,13 +384,12 @@ class HomePatientController extends GetxController {
|
|||
);
|
||||
} catch (e) {
|
||||
print('Error processing notification ${notif.id}: $e');
|
||||
// Continue with other notifications
|
||||
}
|
||||
}
|
||||
|
||||
notifList.sort((a, b) => b.createdAt.compareTo(a.createdAt));
|
||||
|
||||
notificationsList.value = notifList;
|
||||
notificationsList.value = notifList.take(2).toList();
|
||||
isLoadingNotifications.value = false;
|
||||
} catch (e) {
|
||||
print('Error processing notifications: $e');
|
||||
|
|
@ -349,6 +419,7 @@ class HomePatientController extends GetxController {
|
|||
context: context,
|
||||
builder: (BuildContext context) {
|
||||
return AlertDialog(
|
||||
backgroundColor: Colors.white,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(15),
|
||||
),
|
||||
|
|
@ -370,9 +441,7 @@ class HomePatientController extends GetxController {
|
|||
await _openWhatsApp();
|
||||
},
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: const Color(
|
||||
0xFF25D366,
|
||||
), // WhatsApp green color
|
||||
backgroundColor: const Color(0xFF25D366),
|
||||
foregroundColor: Colors.white,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
|
|
@ -395,8 +464,7 @@ class HomePatientController extends GetxController {
|
|||
|
||||
Future<void> _openWhatsApp() async {
|
||||
try {
|
||||
const String nursePhoneNumber =
|
||||
'6281234567890';
|
||||
const String nursePhoneNumber = '6281231901277';
|
||||
|
||||
final String message = Uri.encodeComponent(
|
||||
'Halo, saya ${patientName.value} di ruangan ${currentInfusData.value.room}. '
|
||||
|
|
@ -506,11 +574,19 @@ class HomePatientController extends GetxController {
|
|||
Future<void> refreshData() async {
|
||||
try {
|
||||
await _loadPatientData();
|
||||
// Tidak perlu reload history karena sudah menggunakan stream
|
||||
// History akan auto-update
|
||||
} catch (e) {
|
||||
print('Error refreshing data: $e');
|
||||
AppSnackbar.error('Gagal refresh data: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void clearDeviceAlert(String deviceId) {
|
||||
_alertedDevices.remove(deviceId);
|
||||
_notificationService.clearCooldown(deviceId);
|
||||
}
|
||||
|
||||
void clearAllAlerts() {
|
||||
_alertedDevices.clear();
|
||||
_notificationService.clearAllCooldowns();
|
||||
}
|
||||
}
|
||||
|
|
@ -12,8 +12,6 @@ class HomePatientView extends GetView<HomePatientController> {
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Get.put(HomePatientController());
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.white,
|
||||
body: SafeArea(
|
||||
|
|
@ -33,9 +31,9 @@ class HomePatientView extends GetView<HomePatientController> {
|
|||
Expanded(
|
||||
child: Obx(
|
||||
() => Text(
|
||||
'Halo ${controller.patientName.value}',
|
||||
'Halo Wali Pasien ${controller.patientName.value}',
|
||||
style: const TextStyle(
|
||||
fontSize: 24,
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
|
|
@ -67,36 +65,111 @@ class HomePatientView extends GetView<HomePatientController> {
|
|||
|
||||
const SizedBox(height: 15),
|
||||
|
||||
// ── BUZZER ALERT BANNER ──────────────────────────────────
|
||||
Obx(() {
|
||||
if (!controller.isBuzzerActive.value) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 0, 20, 12),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.red[50],
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: Colors.red[200]!),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(10),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.red[100],
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Icon(
|
||||
Icons.notifications_active_rounded,
|
||||
color: Colors.red[700],
|
||||
size: 26,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Buzzer Aktif!',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: Colors.red[800],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 3),
|
||||
Text(
|
||||
'Tetesan infus berhenti. Harap hubungi perawat.',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.red[600],
|
||||
height: 1.4,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
ElevatedButton(
|
||||
onPressed: controller.turnOffBuzzer,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.red[700],
|
||||
foregroundColor: Colors.white,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 10,
|
||||
),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
elevation: 0,
|
||||
),
|
||||
child: const Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.volume_off_rounded, size: 18),
|
||||
SizedBox(height: 2),
|
||||
Text(
|
||||
'Matikan',
|
||||
style: TextStyle(fontSize: 11),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
// ── END BUZZER ALERT BANNER ──────────────────────────────
|
||||
|
||||
// Monitoring Infus Section
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Text(
|
||||
children: const [
|
||||
Text(
|
||||
'Monitoring Infus',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
// TODO: Navigate to monitoring detail
|
||||
},
|
||||
child: const Text(
|
||||
'See all',
|
||||
style: TextStyle(
|
||||
color: Color(0xFF2196F3),
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Monitoring Card - Gunakan MonitoringCard dari widgets
|
||||
// Monitoring Card
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
child: Obx(() {
|
||||
|
|
@ -114,24 +187,20 @@ class HomePatientView extends GetView<HomePatientController> {
|
|||
|
||||
final infusData = controller.currentInfusData.value;
|
||||
|
||||
// Konversi ke InfusMonitoring untuk MonitoringCard
|
||||
final monitoring = InfusMonitoring(
|
||||
id: controller.deviceId.value,
|
||||
patientName: controller.patientName.value,
|
||||
deviceId: infusData.deviceId,
|
||||
room: infusData.room,
|
||||
roomId: '', // Not needed for display
|
||||
roomId: '',
|
||||
dropsPerMinute: infusData.dropsPerMinute,
|
||||
lastUpdate:
|
||||
DateTime.now(), // Will be calculated from updateTime
|
||||
lastUpdate: DateTime.now(),
|
||||
deviceStatus: infusData.deviceStatus,
|
||||
);
|
||||
|
||||
return MonitoringCard(
|
||||
monitoring: monitoring,
|
||||
onTap: () {
|
||||
// TODO: Navigate to detail
|
||||
},
|
||||
onTap: () {},
|
||||
);
|
||||
}),
|
||||
),
|
||||
|
|
@ -143,31 +212,19 @@ class HomePatientView extends GetView<HomePatientController> {
|
|||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Text(
|
||||
children: const [
|
||||
Text(
|
||||
'History Data',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
// TODO: Navigate to history detail
|
||||
},
|
||||
child: const Text(
|
||||
'See all',
|
||||
style: TextStyle(
|
||||
color: Color(0xFF2196F3),
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Chart Card - Gunakan HistoryGrafikCard dari widgets
|
||||
// Chart Card
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
child: Obx(() {
|
||||
|
|
@ -185,9 +242,7 @@ class HomePatientView extends GetView<HomePatientController> {
|
|||
|
||||
return HistoryGrafikCard(
|
||||
dataPoints: controller.chartDataPoints.toList(),
|
||||
onBookmarkTap: () {
|
||||
// TODO: Bookmark functionality
|
||||
},
|
||||
onBookmarkTap: () {},
|
||||
);
|
||||
}),
|
||||
),
|
||||
|
|
@ -199,26 +254,14 @@ class HomePatientView extends GetView<HomePatientController> {
|
|||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Text(
|
||||
children: const [
|
||||
Text(
|
||||
'History Notifikasi',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
// TODO: Navigate to notifications
|
||||
},
|
||||
child: const Text(
|
||||
'See all',
|
||||
style: TextStyle(
|
||||
color: Color(0xFF2196F3),
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
@ -278,9 +321,7 @@ class HomePatientView extends GetView<HomePatientController> {
|
|||
room: notification.room,
|
||||
deviceId: notification.deviceId,
|
||||
timeAgo: notification.timeAgo,
|
||||
onTap: () {
|
||||
// TODO: Handle notification tap
|
||||
},
|
||||
onTap: () {},
|
||||
);
|
||||
},
|
||||
);
|
||||
|
|
@ -293,7 +334,6 @@ class HomePatientView extends GetView<HomePatientController> {
|
|||
),
|
||||
),
|
||||
|
||||
// Floating Action Button - Call Nurse
|
||||
floatingActionButton: FloatingActionButton(
|
||||
onPressed: () => controller.showCallDialog(context),
|
||||
backgroundColor: const Color(0xFF2196F3),
|
||||
|
|
@ -301,4 +341,4 @@ class HomePatientView extends GetView<HomePatientController> {
|
|||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ import 'package:firebase_auth/firebase_auth.dart';
|
|||
import 'dart:async';
|
||||
import '../../../services/firestore_service.dart';
|
||||
import '../../../services/realtime_database_service.dart';
|
||||
import '../../../services/notification_service.dart';
|
||||
import '../../../models/patient_model.dart';
|
||||
import '../../../models/room_model.dart';
|
||||
import '../../../models/realtime_monitoring_model.dart';
|
||||
|
|
@ -35,6 +36,7 @@ class HomeController extends GetxController {
|
|||
final searchController = TextEditingController();
|
||||
final FirestoreService _firestoreService = FirestoreService();
|
||||
final RealtimeDatabaseService _realtimeService = RealtimeDatabaseService();
|
||||
final NotificationService _notificationService = NotificationService();
|
||||
|
||||
final currentCarouselIndex = 0.obs;
|
||||
final bannerImages = [
|
||||
|
|
@ -45,12 +47,27 @@ class HomeController extends GetxController {
|
|||
final monitoringList = <InfusMonitoring>[].obs;
|
||||
final isLoading = true.obs;
|
||||
|
||||
List<RoomModel> _cachedRooms = [];
|
||||
|
||||
StreamSubscription? _patientsSubscription;
|
||||
StreamSubscription? _realtimeSubscription;
|
||||
StreamSubscription? _roomsSubscription;
|
||||
|
||||
// Map deviceId -> StreamSubscription untuk per-device realtime stream
|
||||
final Map<String, StreamSubscription> _deviceSubscriptions = {};
|
||||
|
||||
// Map deviceId -> data realtime terbaru
|
||||
final Map<String, RealtimeMonitoringModel> _realtimeDataMap = {};
|
||||
|
||||
List<PatientModel> _latestPatients = [];
|
||||
|
||||
bool _patientsReady = false;
|
||||
|
||||
final Set<String> _alertedDevices = {};
|
||||
|
||||
@override
|
||||
void onInit() {
|
||||
super.onInit();
|
||||
_initializeNotificationService();
|
||||
_initializeMonitoring();
|
||||
}
|
||||
|
||||
|
|
@ -58,44 +75,128 @@ class HomeController extends GetxController {
|
|||
void onClose() {
|
||||
searchController.dispose();
|
||||
_patientsSubscription?.cancel();
|
||||
_realtimeSubscription?.cancel();
|
||||
_roomsSubscription?.cancel();
|
||||
// Cancel semua per-device subscriptions
|
||||
for (final sub in _deviceSubscriptions.values) {
|
||||
sub.cancel();
|
||||
}
|
||||
_deviceSubscriptions.clear();
|
||||
super.onClose();
|
||||
}
|
||||
|
||||
Future<void> _initializeNotificationService() async {
|
||||
try {
|
||||
await _notificationService.initialize();
|
||||
} catch (e) {
|
||||
print('Error initializing notification service: $e');
|
||||
}
|
||||
}
|
||||
|
||||
void _initializeMonitoring() {
|
||||
isLoading.value = true;
|
||||
_patientsReady = false;
|
||||
|
||||
// 1. Cache rooms
|
||||
_roomsSubscription = _firestoreService.getRoomsStream().listen((rooms) {
|
||||
_cachedRooms = rooms;
|
||||
});
|
||||
|
||||
// 2. Patients stream
|
||||
_patientsSubscription = _firestoreService.getPatientsStream().listen(
|
||||
(patients) async {
|
||||
_realtimeSubscription = _realtimeService.getAllDevicesStream().listen((
|
||||
realtimeDevices,
|
||||
) {
|
||||
_combineMonitoringData(patients, realtimeDevices);
|
||||
});
|
||||
(patients) {
|
||||
_latestPatients = patients;
|
||||
_patientsReady = true;
|
||||
_syncDeviceSubscriptions(patients);
|
||||
},
|
||||
onError: (error) {
|
||||
print('Error patients stream: $error');
|
||||
isLoading.value = false;
|
||||
AppSnackbar.error('Gagal memuat data');
|
||||
AppSnackbar.error('Gagal memuat data pasien');
|
||||
},
|
||||
);
|
||||
|
||||
// Timeout fallback
|
||||
Future.delayed(const Duration(seconds: 8), () {
|
||||
if (isLoading.value) {
|
||||
print('Timeout: force stop loading');
|
||||
isLoading.value = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _combineMonitoringData(
|
||||
List<PatientModel> patients,
|
||||
List<RealtimeMonitoringModel> realtimeDevices,
|
||||
) async {
|
||||
/// Sinkronisasi per-device stream — ikuti pola HomePatientController
|
||||
/// pakai getDeviceStream(dbRoomId, deviceId) bukan getAllDevicesStream()
|
||||
void _syncDeviceSubscriptions(List<PatientModel> patients) {
|
||||
final currentDeviceIds = patients.map((p) => p.deviceId).toSet();
|
||||
final existingDeviceIds = _deviceSubscriptions.keys.toSet();
|
||||
|
||||
// Cancel stream untuk device yang sudah tidak ada di patients
|
||||
final removedIds = existingDeviceIds.difference(currentDeviceIds);
|
||||
for (final deviceId in removedIds) {
|
||||
_deviceSubscriptions[deviceId]?.cancel();
|
||||
_deviceSubscriptions.remove(deviceId);
|
||||
_realtimeDataMap.remove(deviceId);
|
||||
}
|
||||
|
||||
// Tambah stream untuk device baru
|
||||
final newIds = currentDeviceIds.difference(existingDeviceIds);
|
||||
for (final patient in patients.where((p) => newIds.contains(p.deviceId))) {
|
||||
_subscribeToDevice(patient);
|
||||
}
|
||||
}
|
||||
|
||||
void _subscribeToDevice(PatientModel patient) {
|
||||
// Format room ID sama persis seperti HomePatientController
|
||||
final dbRoomId = patient.roomId.toLowerCase().replaceAll(' ', '_');
|
||||
final deviceId = patient.deviceId;
|
||||
|
||||
print('Subscribing to device: $deviceId in room: $dbRoomId');
|
||||
|
||||
final sub = _realtimeService
|
||||
.getDeviceStream(dbRoomId, deviceId)
|
||||
.listen(
|
||||
(device) {
|
||||
if (device != null) {
|
||||
_realtimeDataMap[deviceId] = device;
|
||||
} else {
|
||||
// Device null = disconnected, hapus dari map
|
||||
_realtimeDataMap.remove(deviceId);
|
||||
}
|
||||
// Rebuild setiap ada update dari device manapun
|
||||
if (_patientsReady) {
|
||||
_rebuildMonitoringList();
|
||||
}
|
||||
},
|
||||
onError: (error) {
|
||||
print('Error stream device $deviceId: $error');
|
||||
// Tetap rebuild walau error, device akan tampil sebagai disconnected
|
||||
if (_patientsReady) {
|
||||
_rebuildMonitoringList();
|
||||
}
|
||||
},
|
||||
cancelOnError: false,
|
||||
);
|
||||
|
||||
_deviceSubscriptions[deviceId] = sub;
|
||||
}
|
||||
|
||||
void _rebuildMonitoringList() {
|
||||
final List<InfusMonitoring> combinedData = [];
|
||||
|
||||
for (var patient in patients) {
|
||||
final realtimeDevice = realtimeDevices.firstWhereOrNull(
|
||||
(device) => device.deviceId == patient.deviceId,
|
||||
);
|
||||
|
||||
final roomName = await _getRoomName(patient.roomId);
|
||||
for (var patient in _latestPatients) {
|
||||
final realtimeDevice = _realtimeDataMap[patient.deviceId];
|
||||
final roomName = _getRoomNameFromCache(patient.roomId);
|
||||
final dropRate = realtimeDevice?.dropRate ?? 0.0;
|
||||
final lastUpdate = realtimeDevice?.lastUpdate ?? DateTime.now();
|
||||
final deviceStatus = realtimeDevice?.deviceStatus ?? 'disconnected';
|
||||
|
||||
_checkDropRate(
|
||||
patient: patient,
|
||||
dropRate: dropRate,
|
||||
roomName: roomName,
|
||||
deviceStatus: deviceStatus,
|
||||
);
|
||||
|
||||
combinedData.add(
|
||||
InfusMonitoring(
|
||||
id: patient.id,
|
||||
|
|
@ -114,16 +215,37 @@ class HomeController extends GetxController {
|
|||
isLoading.value = false;
|
||||
}
|
||||
|
||||
Future<String> _getRoomName(String roomId) async {
|
||||
try {
|
||||
final rooms = await _firestoreService.getRoomsStream().first;
|
||||
final room = rooms.firstWhereOrNull(
|
||||
(r) =>
|
||||
r.id == roomId || r.roomName.toLowerCase() == roomId.toLowerCase(),
|
||||
);
|
||||
return room?.roomName ?? roomId;
|
||||
} catch (e) {
|
||||
return roomId;
|
||||
String _getRoomNameFromCache(String roomId) {
|
||||
final room = _cachedRooms.firstWhereOrNull(
|
||||
(r) => r.id == roomId || r.roomName.toLowerCase() == roomId.toLowerCase(),
|
||||
);
|
||||
return room?.roomName ?? roomId;
|
||||
}
|
||||
|
||||
Future<void> _checkDropRate({
|
||||
required PatientModel patient,
|
||||
required double dropRate,
|
||||
required String roomName,
|
||||
required String deviceStatus,
|
||||
}) async {
|
||||
final deviceId = patient.deviceId;
|
||||
|
||||
if (dropRate == 0.0 && deviceStatus == 'connected') {
|
||||
if (!_alertedDevices.contains(deviceId)) {
|
||||
await _notificationService.showDropRateAlert(
|
||||
patientId: patient.id,
|
||||
patientName: patient.namePatient,
|
||||
deviceId: deviceId,
|
||||
roomName: roomName,
|
||||
);
|
||||
_alertedDevices.add(deviceId);
|
||||
}
|
||||
} else if (dropRate > 0.0) {
|
||||
if (_alertedDevices.contains(deviceId)) {
|
||||
_alertedDevices.remove(deviceId);
|
||||
_notificationService.clearCooldown(deviceId);
|
||||
await _notificationService.cancelNotificationByDeviceId(deviceId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -207,7 +329,9 @@ class HomeController extends GetxController {
|
|||
|
||||
String getLastUpdateText(DateTime lastUpdate) {
|
||||
final difference = DateTime.now().difference(lastUpdate);
|
||||
if (difference.inMinutes < 60) {
|
||||
if (difference.inMinutes < 1) {
|
||||
return 'Just now';
|
||||
} else if (difference.inMinutes < 60) {
|
||||
return '${difference.inMinutes}m ago';
|
||||
} else if (difference.inHours < 24) {
|
||||
return '${difference.inHours}h ago';
|
||||
|
|
@ -215,4 +339,14 @@ class HomeController extends GetxController {
|
|||
return '${difference.inDays}d ago';
|
||||
}
|
||||
}
|
||||
|
||||
void clearDeviceAlert(String deviceId) {
|
||||
_alertedDevices.remove(deviceId);
|
||||
_notificationService.clearCooldown(deviceId);
|
||||
}
|
||||
|
||||
void clearAllAlerts() {
|
||||
_alertedDevices.clear();
|
||||
_notificationService.clearAllCooldowns();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,10 +9,8 @@ class HomeView extends GetView<HomeController> {
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Get.put(HomeController());
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.white,
|
||||
backgroundColor: const Color(0xFFF5F7FA),
|
||||
body: SafeArea(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
|
|
@ -23,8 +21,9 @@ class HomeView extends GetView<HomeController> {
|
|||
currentCarouselIndex: controller.currentCarouselIndex,
|
||||
onPageChanged: controller.updateCarouselIndex,
|
||||
),
|
||||
const SizedBox(height: 15),
|
||||
const SizedBox(height: 16),
|
||||
_buildSectionHeader(),
|
||||
const SizedBox(height: 8),
|
||||
Expanded(child: _buildMonitoringList()),
|
||||
],
|
||||
),
|
||||
|
|
@ -33,24 +32,49 @@ class HomeView extends GetView<HomeController> {
|
|||
}
|
||||
|
||||
Widget _buildHeader(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
return Container(
|
||||
color: Colors.white,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Text(
|
||||
'Halo Perawat',
|
||||
style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Selamat Datang 👋',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: Colors.grey[500],
|
||||
fontWeight: FontWeight.w400,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
const Text(
|
||||
'Halo, Perawat',
|
||||
style: TextStyle(
|
||||
fontSize: 22,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Color(0xFF1A1D2E),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
InkWell(
|
||||
onTap: () => controller.showLogoutDialog(context),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
padding: const EdgeInsets.all(10),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: Colors.grey[300]!),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
color: const Color(0xFFFFF0F0),
|
||||
border: Border.all(color: const Color(0xFFFFCDD2)),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.logout_rounded,
|
||||
color: Colors.red,
|
||||
size: 20,
|
||||
),
|
||||
child: const Icon(Icons.logout, color: Colors.red),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
|
@ -66,15 +90,13 @@ class HomeView extends GetView<HomeController> {
|
|||
children: [
|
||||
const Text(
|
||||
'Monitoring Infus',
|
||||
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Get.toNamed('/all-monitoring'),
|
||||
child: const Text(
|
||||
'Lihat Semua',
|
||||
style: TextStyle(color: Color(0xFF0091EA)),
|
||||
style: TextStyle(
|
||||
fontSize: 17,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Color(0xFF1A1D2E),
|
||||
),
|
||||
),
|
||||
|
||||
],
|
||||
),
|
||||
);
|
||||
|
|
@ -83,52 +105,203 @@ class HomeView extends GetView<HomeController> {
|
|||
Widget _buildMonitoringList() {
|
||||
return Obx(() {
|
||||
if (controller.isLoading.value) {
|
||||
return const Center(
|
||||
child: CircularProgressIndicator(color: Color(0xFF0091EA)),
|
||||
);
|
||||
return _buildLoadingState();
|
||||
}
|
||||
|
||||
if (controller.monitoringList.isEmpty) {
|
||||
return _buildEmptyState();
|
||||
}
|
||||
|
||||
return ListView.builder(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
itemCount: controller.monitoringList.length,
|
||||
itemBuilder: (context, index) {
|
||||
final item = controller.monitoringList[index];
|
||||
return MonitoringCard(
|
||||
monitoring: item,
|
||||
onTap: () => Get.toNamed(
|
||||
'/device-detail',
|
||||
arguments: {
|
||||
'deviceId': item.deviceId,
|
||||
'roomId': item.roomId,
|
||||
},
|
||||
),
|
||||
);
|
||||
return RefreshIndicator(
|
||||
color: const Color(0xFF0091EA),
|
||||
onRefresh: () async {
|
||||
// Trigger refresh — stream akan otomatis update,
|
||||
// ini hanya untuk UX pull-to-refresh
|
||||
await Future.delayed(const Duration(milliseconds: 800));
|
||||
},
|
||||
child: ListView.builder(
|
||||
padding: const EdgeInsets.fromLTRB(20, 4, 20, 20),
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
itemCount: controller.monitoringList.length,
|
||||
itemBuilder: (context, index) {
|
||||
final item = controller.monitoringList[index];
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: MonitoringCard(
|
||||
monitoring: item,
|
||||
onTap: () => Get.toNamed(
|
||||
'/device-detail',
|
||||
arguments: {'deviceId': item.deviceId, 'roomId': item.roomId},
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
Widget _buildEmptyState() {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.medical_services_outlined,
|
||||
size: 80,
|
||||
color: Colors.grey[400],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Tidak ada data monitoring',
|
||||
style: TextStyle(fontSize: 16, color: Colors.grey[600]),
|
||||
Widget _buildLoadingState() {
|
||||
return ListView.builder(
|
||||
padding: const EdgeInsets.fromLTRB(20, 4, 20, 20),
|
||||
itemCount: 4,
|
||||
itemBuilder: (context, index) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: _buildSkeletonCard(),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSkeletonCard() {
|
||||
return Container(
|
||||
height: 110,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.04),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
_shimmerBox(width: 36, height: 36, radius: 10),
|
||||
const SizedBox(width: 12),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_shimmerBox(width: 140, height: 13, radius: 6),
|
||||
const SizedBox(height: 6),
|
||||
_shimmerBox(width: 90, height: 11, radius: 6),
|
||||
],
|
||||
),
|
||||
const Spacer(),
|
||||
_shimmerBox(width: 60, height: 26, radius: 20),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
_shimmerBox(width: 80, height: 11, radius: 6),
|
||||
const SizedBox(width: 16),
|
||||
_shimmerBox(width: 60, height: 11, radius: 6),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Widget _shimmerBox({
|
||||
required double width,
|
||||
required double height,
|
||||
double radius = 4,
|
||||
}) {
|
||||
return _ShimmerWidget(
|
||||
child: Container(
|
||||
width: width,
|
||||
height: height,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey[200],
|
||||
borderRadius: BorderRadius.circular(radius),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildEmptyState() {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 40),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Container(
|
||||
width: 100,
|
||||
height: 100,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFE3F2FD),
|
||||
borderRadius: BorderRadius.circular(30),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.water_drop_outlined,
|
||||
size: 48,
|
||||
color: Color(0xFF0091EA),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
const Text(
|
||||
'Belum Ada Data',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Color(0xFF1A1D2E),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Data monitoring infus akan tampil di sini setelah perangkat terhubung.',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: Colors.grey[500],
|
||||
height: 1.5,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Widget shimmer animasi untuk skeleton loading
|
||||
class _ShimmerWidget extends StatefulWidget {
|
||||
final Widget child;
|
||||
const _ShimmerWidget({required this.child});
|
||||
|
||||
@override
|
||||
State<_ShimmerWidget> createState() => _ShimmerWidgetState();
|
||||
}
|
||||
|
||||
class _ShimmerWidgetState extends State<_ShimmerWidget>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late AnimationController _controller;
|
||||
late Animation<double> _animation;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 1200),
|
||||
)..repeat(reverse: true);
|
||||
_animation = Tween<double>(
|
||||
begin: 0.4,
|
||||
end: 1.0,
|
||||
).animate(CurvedAnimation(parent: _controller, curve: Curves.easeInOut));
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FadeTransition(opacity: _animation, child: widget.child);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,7 +18,6 @@ class LoginController extends GetxController {
|
|||
}
|
||||
|
||||
void login() async {
|
||||
// Validasi input
|
||||
if (usernameController.text.isEmpty || passwordController.text.isEmpty) {
|
||||
Get.snackbar(
|
||||
'Error',
|
||||
|
|
@ -30,7 +29,6 @@ class LoginController extends GetxController {
|
|||
return;
|
||||
}
|
||||
|
||||
// Validasi format email
|
||||
if (!GetUtils.isEmail(usernameController.text)) {
|
||||
Get.snackbar(
|
||||
'Error',
|
||||
|
|
@ -45,27 +43,23 @@ class LoginController extends GetxController {
|
|||
try {
|
||||
isLoading.value = true;
|
||||
|
||||
// Login dengan Firebase Authentication
|
||||
UserCredential userCredential = await _auth.signInWithEmailAndPassword(
|
||||
email: usernameController.text.trim(),
|
||||
password: passwordController.text.trim(),
|
||||
);
|
||||
|
||||
if (userCredential.user != null) {
|
||||
// Cek apakah user ada di collection users
|
||||
final userDoc = await _firestore
|
||||
.collection('users')
|
||||
.doc(userCredential.user!.uid)
|
||||
.get();
|
||||
|
||||
if (userDoc.exists) {
|
||||
// User ada di collection users (Guardian/Patient)
|
||||
final userData = userDoc.data()!;
|
||||
final deviceId = userData['device_id'] as String?;
|
||||
final userName = userData['name'] as String? ?? 'User';
|
||||
|
||||
if (deviceId != null && deviceId.isNotEmpty) {
|
||||
// User adalah guardian/patient dengan device_id
|
||||
Get.snackbar(
|
||||
'Berhasil',
|
||||
'Selamat datang, $userName!',
|
||||
|
|
@ -74,7 +68,6 @@ class LoginController extends GetxController {
|
|||
snackPosition: SnackPosition.TOP,
|
||||
);
|
||||
|
||||
// Navigasi ke home_patient dengan device_id
|
||||
Get.offAllNamed(
|
||||
'/home-patient',
|
||||
arguments: {
|
||||
|
|
@ -84,7 +77,6 @@ class LoginController extends GetxController {
|
|||
},
|
||||
);
|
||||
} else {
|
||||
// User ada tapi tidak punya device_id
|
||||
Get.snackbar(
|
||||
'Error',
|
||||
'Akun Anda belum terdaftar dengan device',
|
||||
|
|
@ -95,7 +87,6 @@ class LoginController extends GetxController {
|
|||
await _auth.signOut();
|
||||
}
|
||||
} else {
|
||||
// User TIDAK ada di collection users = Perawat/Admin
|
||||
Get.snackbar(
|
||||
'Berhasil',
|
||||
'Login berhasil! Selamat datang, Perawat',
|
||||
|
|
@ -104,7 +95,6 @@ class LoginController extends GetxController {
|
|||
snackPosition: SnackPosition.TOP,
|
||||
);
|
||||
|
||||
// Navigasi ke navbar/home perawat
|
||||
Get.offAllNamed('/navbar');
|
||||
}
|
||||
}
|
||||
|
|
@ -156,12 +146,20 @@ class LoginController extends GetxController {
|
|||
}
|
||||
|
||||
void contactNurse() async {
|
||||
const phoneNumber = '+6281231901277'; // Tanpa spasi
|
||||
final url = Uri.parse('https://wa.me/$phoneNumber');
|
||||
const phoneNumber = '6281231901277';
|
||||
final message = Uri.encodeComponent(
|
||||
'Halo, saya wali pasien dari (Nama Pasien) '
|
||||
'Saya ingin meminta akun untuk login ke aplikasi Smart Infus.',
|
||||
);
|
||||
|
||||
// Construct the WhatsApp URL with the message
|
||||
final url = Uri.parse('https://wa.me/$phoneNumber?text=$message');
|
||||
|
||||
// Attempt to launch WhatsApp
|
||||
if (await canLaunchUrl(url)) {
|
||||
await launchUrl(url, mode: LaunchMode.externalApplication);
|
||||
} else {
|
||||
// Show an error if WhatsApp can't be opened
|
||||
Get.snackbar(
|
||||
'Error',
|
||||
'Tidak dapat membuka WhatsApp',
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import 'package:get/get.dart';
|
|||
import '../controllers/navbar_controller.dart';
|
||||
import '../../home/controllers/home_controller.dart';
|
||||
import '../../notification/controllers/notification_controller.dart';
|
||||
import '../../schedule/controllers/schedule_controller.dart';
|
||||
// import '../../schedule/controllers/schedule_controller.dart';
|
||||
import '../../profile/controllers/profile_controller.dart';
|
||||
|
||||
class NavbarBinding extends Bindings {
|
||||
|
|
@ -11,7 +11,7 @@ class NavbarBinding extends Bindings {
|
|||
Get.lazyPut<NavbarController>(() => NavbarController());
|
||||
Get.lazyPut<HomeController>(() => HomeController());
|
||||
Get.lazyPut<NotificationController>(() => NotificationController());
|
||||
Get.lazyPut<ScheduleController>(() => ScheduleController());
|
||||
// Get.lazyPut<ScheduleController>(() => ScheduleController());
|
||||
Get.lazyPut<ProfileController>(() => ProfileController());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import 'package:get/get.dart';
|
|||
import '../controllers/navbar_controller.dart';
|
||||
import '../../home/views/home_view.dart';
|
||||
import '../../notification/views/notification_view.dart';
|
||||
import '../../schedule/views/schedule_view.dart';
|
||||
// import '../../schedule/views/schedule_view.dart';
|
||||
import '../../profile/views/profile_view.dart';
|
||||
|
||||
class NavbarView extends StatelessWidget {
|
||||
|
|
@ -16,7 +16,7 @@ class NavbarView extends StatelessWidget {
|
|||
final pages = const [
|
||||
HomeView(),
|
||||
NotificationView(),
|
||||
ScheduleView(),
|
||||
// ScheduleView(),
|
||||
ProfileView(),
|
||||
];
|
||||
|
||||
|
|
@ -52,8 +52,8 @@ class NavbarView extends StatelessWidget {
|
|||
children: [
|
||||
_navItem(controller, Icons.home, 0, 'Home'),
|
||||
_navItem(controller, Icons.chat_bubble_outline, 1, 'Notification'),
|
||||
_navItem(controller, Icons.calendar_today, 2, 'Schedule'),
|
||||
_navItem(controller, Icons.person_outline, 3, 'Account'),
|
||||
// _navItem(controller, Icons.calendar_today, 2, 'Schedule'),
|
||||
_navItem(controller, Icons.person_outline, 2, 'Account'),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -53,7 +53,7 @@ class NotificationItem {
|
|||
|
||||
class NotificationController extends GetxController {
|
||||
final FirestoreService _firestoreService = FirestoreService();
|
||||
|
||||
|
||||
final selectedTab = 0.obs;
|
||||
final _allNotifications = <NotificationItem>[].obs;
|
||||
final isLoading = true.obs;
|
||||
|
|
@ -76,47 +76,51 @@ class NotificationController extends GetxController {
|
|||
void _initializeNotifications() {
|
||||
isLoading.value = true;
|
||||
|
||||
_notificationsSubscription = _firestoreService
|
||||
.getNotificationsStream()
|
||||
.listen((notificationModels) async {
|
||||
final List<NotificationItem> items = [];
|
||||
_notificationsSubscription = _firestoreService.getNotificationsStream().listen(
|
||||
(notificationModels) async {
|
||||
final List<NotificationItem> items = [];
|
||||
|
||||
for (var notifModel in notificationModels) {
|
||||
// Get patient info
|
||||
final patient = await _firestoreService.getPatientById(
|
||||
notifModel.patientId,
|
||||
);
|
||||
|
||||
// Get room name from patient's roomId
|
||||
String roomName = 'Unknown Room';
|
||||
if (patient != null && patient.roomId.isNotEmpty) {
|
||||
final rooms = await _firestoreService.getRoomsStream().first;
|
||||
final room = rooms.firstWhereOrNull(
|
||||
(r) => r.roomName.toLowerCase() == patient.roomId.toLowerCase(),
|
||||
for (var notifModel in notificationModels) {
|
||||
// Get patient info
|
||||
final patient = await _firestoreService.getPatientById(
|
||||
notifModel.patientId,
|
||||
);
|
||||
|
||||
// Get room name from patient's roomId
|
||||
String roomName = 'Unknown Room';
|
||||
if (patient != null && patient.roomId.isNotEmpty) {
|
||||
final rooms = await _firestoreService.getRoomsStream().first;
|
||||
final room = rooms.firstWhereOrNull(
|
||||
(r) =>
|
||||
r.id ==
|
||||
patient
|
||||
.roomId, // ✅ UBAH INI - cari berdasarkan ID, bukan roomName
|
||||
);
|
||||
roomName = room?.roomName ?? patient.roomId;
|
||||
}
|
||||
|
||||
items.add(
|
||||
NotificationItem(
|
||||
id: notifModel.id,
|
||||
title: notifModel.title,
|
||||
message: notifModel.message,
|
||||
room: roomName,
|
||||
deviceId: notifModel.deviceId,
|
||||
patientName: patient?.namePatient ?? 'Unknown Patient',
|
||||
time: notifModel.createdAt,
|
||||
isRead: notifModel.isRead,
|
||||
),
|
||||
);
|
||||
roomName = room?.roomName ?? patient.roomId;
|
||||
}
|
||||
|
||||
items.add(
|
||||
NotificationItem(
|
||||
id: notifModel.id,
|
||||
title: notifModel.title,
|
||||
message: notifModel.message,
|
||||
room: roomName,
|
||||
deviceId: notifModel.deviceId,
|
||||
patientName: patient?.namePatient ?? 'Unknown Patient',
|
||||
time: notifModel.createdAt,
|
||||
isRead: notifModel.isRead,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
_allNotifications.value = items;
|
||||
isLoading.value = false;
|
||||
}, onError: (error) {
|
||||
print('Error listening to notifications: $error');
|
||||
isLoading.value = false;
|
||||
});
|
||||
_allNotifications.value = items;
|
||||
isLoading.value = false;
|
||||
},
|
||||
onError: (error) {
|
||||
print('Error listening to notifications: $error');
|
||||
isLoading.value = false;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Computed property untuk notifikasi yang difilter
|
||||
|
|
@ -205,4 +209,4 @@ class NotificationController extends GetxController {
|
|||
print('Error adding notification: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -87,15 +87,6 @@ class NotificationView extends GetView<NotificationController> {
|
|||
notification: notif,
|
||||
onDismissed: () {
|
||||
controller.deleteNotification(notif.id);
|
||||
Get.snackbar(
|
||||
'Notifikasi Dihapus',
|
||||
'Notifikasi telah dihapus',
|
||||
snackPosition: SnackPosition.BOTTOM,
|
||||
duration: const Duration(seconds: 2),
|
||||
margin: const EdgeInsets.all(16),
|
||||
backgroundColor: Colors.grey[800],
|
||||
colorText: Colors.white,
|
||||
);
|
||||
},
|
||||
onTap: !notif.isRead ? () {
|
||||
controller.markAsRead(notif.id);
|
||||
|
|
@ -105,15 +96,6 @@ class NotificationView extends GetView<NotificationController> {
|
|||
controller.changeTab(1);
|
||||
});
|
||||
|
||||
Get.snackbar(
|
||||
'Notifikasi Dibaca',
|
||||
'Notifikasi telah dipindahkan ke sudah dibaca',
|
||||
snackPosition: SnackPosition.BOTTOM,
|
||||
duration: const Duration(seconds: 2),
|
||||
margin: const EdgeInsets.all(16),
|
||||
backgroundColor: const Color(0xFF0091EA),
|
||||
colorText: Colors.white,
|
||||
);
|
||||
} : null,
|
||||
);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -369,7 +369,7 @@ class ProfileController extends GetxController {
|
|||
},
|
||||
'monitoring': {
|
||||
'device_status': 'connected',
|
||||
'drop_rate': 0,
|
||||
'drop_rate': 1,
|
||||
'last_update': DateTime.now().toIso8601String(),
|
||||
},
|
||||
'settings': {'servo_angle_open': 90, 'servo_angle_close': 0},
|
||||
|
|
|
|||
|
|
@ -5,7 +5,8 @@ import '../../../models/schedule_model.dart';
|
|||
import '../../../widgets/app_snackbar.dart';
|
||||
|
||||
class PatientSchedule {
|
||||
final String id;
|
||||
final String id;
|
||||
final String patientId;
|
||||
final String name;
|
||||
final String room;
|
||||
final String deviceId;
|
||||
|
|
@ -15,6 +16,7 @@ class PatientSchedule {
|
|||
|
||||
PatientSchedule({
|
||||
required this.id,
|
||||
required this.patientId,
|
||||
required this.name,
|
||||
required this.room,
|
||||
required this.deviceId,
|
||||
|
|
@ -61,45 +63,48 @@ class ScheduleController extends GetxController {
|
|||
_schedulesSubscription = _firestoreService
|
||||
.getSchedulesStream(date: selectedDate.value)
|
||||
.listen(
|
||||
(scheduleModels) async {
|
||||
try {
|
||||
final List<PatientSchedule> items = [];
|
||||
|
||||
for (var scheduleModel in scheduleModels) {
|
||||
(scheduleModels) async {
|
||||
try {
|
||||
final patient = await _firestoreService.getPatientById(scheduleModel.patientId);
|
||||
final List<PatientSchedule> items = [];
|
||||
|
||||
if (patient != null) {
|
||||
final roomName = await _getRoomName(patient.roomId);
|
||||
items.add(
|
||||
PatientSchedule(
|
||||
id: scheduleModel.id,
|
||||
name: patient.namePatient,
|
||||
room: roomName,
|
||||
deviceId: patient.deviceId,
|
||||
medicineTime: scheduleModel.medicineDetail,
|
||||
fluidTime: scheduleModel.fluidDetail,
|
||||
timeOfDay: scheduleModel.timeOfDay,
|
||||
),
|
||||
);
|
||||
for (var scheduleModel in scheduleModels) {
|
||||
try {
|
||||
final patient = await _firestoreService.getPatientById(
|
||||
scheduleModel.patientId,
|
||||
);
|
||||
|
||||
if (patient != null) {
|
||||
final roomName = await _getRoomName(patient.roomId);
|
||||
items.add(
|
||||
PatientSchedule(
|
||||
id: scheduleModel.id,
|
||||
patientId: scheduleModel.patientId,
|
||||
name: patient.namePatient,
|
||||
room: roomName,
|
||||
deviceId: patient.deviceId,
|
||||
medicineTime: scheduleModel.medicineDetail,
|
||||
fluidTime: scheduleModel.fluidDetail,
|
||||
timeOfDay: scheduleModel.timeOfDay,
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
allSchedules.value = items;
|
||||
_applyTimeFilter();
|
||||
isLoading.value = false;
|
||||
} catch (e) {
|
||||
isLoading.value = false;
|
||||
}
|
||||
},
|
||||
onError: (error) {
|
||||
isLoading.value = false;
|
||||
AppSnackbar.error('Gagal memuat jadwal');
|
||||
},
|
||||
);
|
||||
allSchedules.value = items;
|
||||
_applyTimeFilter();
|
||||
isLoading.value = false;
|
||||
} catch (e) {
|
||||
isLoading.value = false;
|
||||
}
|
||||
},
|
||||
onError: (error) {
|
||||
isLoading.value = false;
|
||||
AppSnackbar.error('Gagal memuat jadwal');
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _applyTimeFilter() {
|
||||
|
|
@ -113,7 +118,8 @@ class ScheduleController extends GetxController {
|
|||
try {
|
||||
final rooms = await _firestoreService.getRoomsStream().first;
|
||||
final room = rooms.firstWhereOrNull(
|
||||
(r) => r.id == roomId || r.roomName.toLowerCase() == roomId.toLowerCase(),
|
||||
(r) =>
|
||||
r.id == roomId || r.roomName.toLowerCase() == roomId.toLowerCase(),
|
||||
);
|
||||
return room?.roomName ?? roomId;
|
||||
} catch (e) {
|
||||
|
|
@ -135,16 +141,14 @@ class ScheduleController extends GetxController {
|
|||
_loadSchedules();
|
||||
}
|
||||
|
||||
Future<void> addSchedule(PatientSchedule schedule, DateTime scheduleDate) async {
|
||||
Future<void> addSchedule(
|
||||
PatientSchedule schedule,
|
||||
DateTime scheduleDate,
|
||||
) async {
|
||||
try {
|
||||
final patients = await _firestoreService.getPatientsStream().first;
|
||||
final patient = patients.firstWhereOrNull((p) => p.id == schedule.id);
|
||||
|
||||
if (patient == null) throw Exception('Pasien tidak ditemukan');
|
||||
|
||||
final scheduleModel = ScheduleModel(
|
||||
id: '',
|
||||
patientId: patient.id,
|
||||
patientId: schedule.patientId,
|
||||
medicineDetail: schedule.medicineTime,
|
||||
fluidDetail: schedule.fluidTime,
|
||||
scheduleDate: scheduleDate,
|
||||
|
|
@ -155,20 +159,19 @@ class ScheduleController extends GetxController {
|
|||
await _firestoreService.addSchedule(scheduleModel);
|
||||
AppSnackbar.success('Jadwal berhasil ditambahkan');
|
||||
} catch (e) {
|
||||
AppSnackbar.error('Gagal menambahkan jadwal');
|
||||
AppSnackbar.error('Gagal menambahkan jadwal: ${e.toString()}');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> updateSchedule(String id, PatientSchedule updatedSchedule, DateTime scheduleDate) async {
|
||||
Future<void> updateSchedule(
|
||||
String scheduleId,
|
||||
PatientSchedule updatedSchedule,
|
||||
DateTime scheduleDate,
|
||||
) async {
|
||||
try {
|
||||
final patients = await _firestoreService.getPatientsStream().first;
|
||||
final patient = patients.firstWhereOrNull((p) => p.id == updatedSchedule.id);
|
||||
|
||||
if (patient == null) throw Exception('Pasien tidak ditemukan');
|
||||
|
||||
final scheduleModel = ScheduleModel(
|
||||
id: id,
|
||||
patientId: patient.id,
|
||||
id: scheduleId,
|
||||
patientId: updatedSchedule.patientId,
|
||||
medicineDetail: updatedSchedule.medicineTime,
|
||||
fluidDetail: updatedSchedule.fluidTime,
|
||||
scheduleDate: scheduleDate,
|
||||
|
|
@ -176,10 +179,10 @@ class ScheduleController extends GetxController {
|
|||
createdAt: DateTime.now(),
|
||||
);
|
||||
|
||||
await _firestoreService.updateSchedule(id, scheduleModel);
|
||||
await _firestoreService.updateSchedule(scheduleId, scheduleModel);
|
||||
AppSnackbar.success('Jadwal berhasil diperbarui');
|
||||
} catch (e) {
|
||||
AppSnackbar.error('Gagal memperbarui jadwal');
|
||||
AppSnackbar.error('Gagal memperbarui jadwal: ${e.toString()}');
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -188,7 +191,7 @@ class ScheduleController extends GetxController {
|
|||
await _firestoreService.deleteSchedule(id);
|
||||
AppSnackbar.success('Jadwal berhasil dihapus');
|
||||
} catch (e) {
|
||||
AppSnackbar.error('Gagal menghapus jadwal');
|
||||
AppSnackbar.error('Gagal menghapus jadwal: ${e.toString()}');
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -196,9 +199,13 @@ class ScheduleController extends GetxController {
|
|||
try {
|
||||
final patients = await _firestoreService.getPatientsStream().first;
|
||||
final List<Map<String, String>> result = [];
|
||||
final Set<String> seenIds = {};
|
||||
|
||||
for (var patient in patients) {
|
||||
try {
|
||||
// Skip jika ID sudah pernah ditambahkan (mencegah duplikasi)
|
||||
if (seenIds.contains(patient.id)) continue;
|
||||
|
||||
final roomName = await _getRoomName(patient.roomId);
|
||||
result.add({
|
||||
'id': patient.id,
|
||||
|
|
@ -206,6 +213,8 @@ class ScheduleController extends GetxController {
|
|||
'room': roomName,
|
||||
'device': patient.deviceId,
|
||||
});
|
||||
|
||||
seenIds.add(patient.id);
|
||||
} catch (e) {
|
||||
continue;
|
||||
}
|
||||
|
|
@ -216,4 +225,4 @@ class ScheduleController extends GetxController {
|
|||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
import 'package:get/get.dart';
|
||||
import 'package:firebase_auth/firebase_auth.dart';
|
||||
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||
import '../../../routes/app_pages.dart';
|
||||
|
||||
class SplashScreenController extends GetxController {
|
||||
final FirebaseAuth _auth = FirebaseAuth.instance;
|
||||
final FirebaseFirestore _firestore = FirebaseFirestore.instance;
|
||||
|
||||
@override
|
||||
void onInit() {
|
||||
|
|
@ -13,16 +15,44 @@ class SplashScreenController extends GetxController {
|
|||
|
||||
void _checkAuthStatus() async {
|
||||
await Future.delayed(const Duration(seconds: 4));
|
||||
|
||||
// Cek apakah user sudah login
|
||||
|
||||
User? user = _auth.currentUser;
|
||||
|
||||
|
||||
if (user != null) {
|
||||
// User sudah login, langsung ke home/navbar
|
||||
Get.offAllNamed(Routes.NAVBAR);
|
||||
try {
|
||||
final userDoc = await _firestore
|
||||
.collection('users')
|
||||
.doc(user.uid)
|
||||
.get();
|
||||
|
||||
if (userDoc.exists) {
|
||||
final userData = userDoc.data()!;
|
||||
final deviceId = userData['device_id'] as String?;
|
||||
final userName = userData['name'] as String? ?? 'User';
|
||||
|
||||
if (deviceId != null && deviceId.isNotEmpty) {
|
||||
Get.offAllNamed(
|
||||
Routes.HOME_PATIENT,
|
||||
arguments: {
|
||||
'deviceId': deviceId,
|
||||
'userName': userName,
|
||||
'userEmail': user.email ?? '',
|
||||
},
|
||||
);
|
||||
} else {
|
||||
await _auth.signOut();
|
||||
Get.offAllNamed(Routes.LOGIN);
|
||||
}
|
||||
} else {
|
||||
Get.offAllNamed(Routes.NAVBAR);
|
||||
}
|
||||
} catch (e) {
|
||||
print('Error checking user data: $e');
|
||||
await _auth.signOut();
|
||||
Get.offAllNamed(Routes.LOGIN);
|
||||
}
|
||||
} else {
|
||||
// User belum login, ke halaman login
|
||||
Get.offAllNamed(Routes.LOGIN);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ class SplashScreenView extends GetView<SplashScreenController> {
|
|||
),
|
||||
const SizedBox(height: 30),
|
||||
const Text(
|
||||
'Smart Infuse',
|
||||
'SMART INFUSE',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 32,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,261 @@
|
|||
// lib/app/services/notification_service.dart
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:awesome_notifications/awesome_notifications.dart';
|
||||
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||
import '../models/notification_model.dart' as local;
|
||||
|
||||
class NotificationService {
|
||||
static final NotificationService _instance = NotificationService._internal();
|
||||
factory NotificationService() => _instance;
|
||||
NotificationService._internal();
|
||||
|
||||
final FirebaseFirestore _firestore = FirebaseFirestore.instance;
|
||||
final Map<String, DateTime> _lastNotificationTime = {};
|
||||
final Duration _notificationCooldown = const Duration(minutes: 5);
|
||||
|
||||
Future<void> initialize() async {
|
||||
await AwesomeNotifications().initialize(null, [
|
||||
NotificationChannel(
|
||||
channelKey: 'infus_alert_channel',
|
||||
channelName: 'Infus Alerts',
|
||||
channelDescription: 'Alerts for infusion monitoring',
|
||||
defaultColor: const Color(0xFF9D50DD),
|
||||
ledColor: Colors.white,
|
||||
importance: NotificationImportance.High,
|
||||
channelShowBadge: true,
|
||||
playSound: true,
|
||||
enableVibration: true,
|
||||
),
|
||||
]);
|
||||
|
||||
await _requestPermissions();
|
||||
_setupNotificationListeners();
|
||||
}
|
||||
|
||||
Future<void> _requestPermissions() async {
|
||||
await AwesomeNotifications().isNotificationAllowed().then((
|
||||
isAllowed,
|
||||
) async {
|
||||
if (!isAllowed) {
|
||||
await AwesomeNotifications().requestPermissionToSendNotifications();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _setupNotificationListeners() {
|
||||
AwesomeNotifications().setListeners(
|
||||
onActionReceivedMethod: _onNotificationTapped,
|
||||
onNotificationCreatedMethod: _onNotificationCreated,
|
||||
onNotificationDisplayedMethod: _onNotificationDisplayed,
|
||||
onDismissActionReceivedMethod: _onNotificationDismissed,
|
||||
);
|
||||
}
|
||||
|
||||
@pragma("vm:entry-point")
|
||||
static Future<void> _onNotificationCreated(
|
||||
ReceivedNotification receivedNotification,
|
||||
) async {
|
||||
print('Notification created: ${receivedNotification.id}');
|
||||
}
|
||||
|
||||
@pragma("vm:entry-point")
|
||||
static Future<void> _onNotificationDisplayed(
|
||||
ReceivedNotification receivedNotification,
|
||||
) async {
|
||||
print('Notification displayed: ${receivedNotification.id}');
|
||||
}
|
||||
|
||||
@pragma("vm:entry-point")
|
||||
static Future<void> _onNotificationTapped(
|
||||
ReceivedAction receivedAction,
|
||||
) async {
|
||||
print('Notification tapped: ${receivedAction.payload}');
|
||||
}
|
||||
|
||||
@pragma("vm:entry-point")
|
||||
static Future<void> _onNotificationDismissed(
|
||||
ReceivedAction receivedAction,
|
||||
) async {
|
||||
print('Notification dismissed: ${receivedAction.id}');
|
||||
}
|
||||
|
||||
Future<void> showDropRateAlert({
|
||||
required String patientId,
|
||||
required String patientName,
|
||||
required String deviceId,
|
||||
required String roomName,
|
||||
}) async {
|
||||
if (_isInCooldown(deviceId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
final title = 'Peringatan Infus ⚠️';
|
||||
final message =
|
||||
'Infus $patientName di ruang $roomName telah berhenti menetes! Segera lakukan Tindakan!';
|
||||
|
||||
await AwesomeNotifications().createNotification(
|
||||
content: NotificationContent(
|
||||
id: deviceId.hashCode,
|
||||
channelKey: 'infus_alert_channel',
|
||||
title: title,
|
||||
body: message,
|
||||
notificationLayout: NotificationLayout.Default,
|
||||
payload: {
|
||||
'device_id': deviceId,
|
||||
'patient_id': patientId,
|
||||
'patient_name': patientName,
|
||||
'room_name': roomName,
|
||||
},
|
||||
category: NotificationCategory.Alarm,
|
||||
wakeUpScreen: true,
|
||||
fullScreenIntent: true,
|
||||
autoDismissible: false,
|
||||
backgroundColor: Colors.red,
|
||||
color: Colors.red,
|
||||
),
|
||||
actionButtons: [
|
||||
NotificationActionButton(
|
||||
key: 'VIEW',
|
||||
label: 'Lihat Detail',
|
||||
actionType: ActionType.Default,
|
||||
),
|
||||
NotificationActionButton(
|
||||
key: 'DISMISS',
|
||||
label: 'Tutup',
|
||||
actionType: ActionType.DismissAction,
|
||||
isDangerousOption: true,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
await _saveNotificationToFirestore(
|
||||
patientId: patientId,
|
||||
deviceId: deviceId,
|
||||
title: title,
|
||||
message: message,
|
||||
);
|
||||
|
||||
_lastNotificationTime[deviceId] = DateTime.now();
|
||||
print('Notification sent for device: $deviceId');
|
||||
}
|
||||
|
||||
Future<void> showDropRateAlertForPatient({
|
||||
required String patientId,
|
||||
required String patientName,
|
||||
required String deviceId,
|
||||
required String roomName,
|
||||
}) async {
|
||||
if (_isInCooldown(deviceId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
final title = 'Peringatan Infus ⚠️';
|
||||
final message =
|
||||
'Infus $patientName di ruang $roomName telah berhenti menetes! Segera hubungi Perawat!';
|
||||
|
||||
await AwesomeNotifications().createNotification(
|
||||
content: NotificationContent(
|
||||
id: deviceId.hashCode,
|
||||
channelKey: 'infus_alert_channel',
|
||||
title: title,
|
||||
body: message,
|
||||
notificationLayout: NotificationLayout.Default,
|
||||
payload: {
|
||||
'device_id': deviceId,
|
||||
'patient_id': patientId,
|
||||
'patient_name': patientName,
|
||||
'room_name': roomName,
|
||||
},
|
||||
category: NotificationCategory.Alarm,
|
||||
wakeUpScreen: true,
|
||||
fullScreenIntent: true,
|
||||
autoDismissible: false,
|
||||
backgroundColor: Colors.red,
|
||||
color: Colors.red,
|
||||
),
|
||||
actionButtons: [
|
||||
NotificationActionButton(
|
||||
key: 'CALL',
|
||||
label: 'Hubungi Perawat',
|
||||
actionType: ActionType.Default,
|
||||
),
|
||||
NotificationActionButton(
|
||||
key: 'DISMISS',
|
||||
label: 'Tutup',
|
||||
actionType: ActionType.DismissAction,
|
||||
isDangerousOption: true,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
await _saveNotificationToFirestore(
|
||||
patientId: patientId,
|
||||
deviceId: deviceId,
|
||||
title: title,
|
||||
message: message,
|
||||
);
|
||||
|
||||
_lastNotificationTime[deviceId] = DateTime.now();
|
||||
print('Notification sent for patient device: $deviceId');
|
||||
}
|
||||
|
||||
Future<void> _saveNotificationToFirestore({
|
||||
required String patientId,
|
||||
required String deviceId,
|
||||
required String title,
|
||||
required String message,
|
||||
}) async {
|
||||
try {
|
||||
final notification = local.NotificationModel(
|
||||
id: '',
|
||||
patientId: patientId,
|
||||
deviceId: deviceId,
|
||||
title: title,
|
||||
message: message,
|
||||
isRead: false,
|
||||
createdAt: DateTime.now(),
|
||||
);
|
||||
|
||||
await _firestore
|
||||
.collection('notifications')
|
||||
.add(notification.toFirestore());
|
||||
print('Notification saved to Firestore');
|
||||
} catch (e) {
|
||||
print('Error saving notification to Firestore: $e');
|
||||
}
|
||||
}
|
||||
|
||||
bool _isInCooldown(String deviceId) {
|
||||
if (!_lastNotificationTime.containsKey(deviceId)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
final lastTime = _lastNotificationTime[deviceId]!;
|
||||
final difference = DateTime.now().difference(lastTime);
|
||||
return difference < _notificationCooldown;
|
||||
}
|
||||
|
||||
void clearCooldown(String deviceId) {
|
||||
_lastNotificationTime.remove(deviceId);
|
||||
}
|
||||
|
||||
void clearAllCooldowns() {
|
||||
_lastNotificationTime.clear();
|
||||
}
|
||||
|
||||
Future<void> cancelNotification(int id) async {
|
||||
await AwesomeNotifications().cancel(id);
|
||||
}
|
||||
|
||||
Future<void> cancelNotificationByDeviceId(String deviceId) async {
|
||||
await AwesomeNotifications().cancel(deviceId.hashCode);
|
||||
}
|
||||
|
||||
Future<void> cancelAllNotifications() async {
|
||||
await AwesomeNotifications().cancelAll();
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
_lastNotificationTime.clear();
|
||||
}
|
||||
}
|
||||
|
|
@ -9,10 +9,9 @@ class RealtimeDatabaseService {
|
|||
Stream<Map<String, RealtimeMonitoringModel>> getRoomDevicesStream(
|
||||
String roomId,
|
||||
) {
|
||||
return _database
|
||||
.child('realtime/rooms/$roomId/devices')
|
||||
.onValue
|
||||
.map((event) {
|
||||
return _database.child('realtime/rooms/$roomId/devices').onValue.map((
|
||||
event,
|
||||
) {
|
||||
final Map<String, RealtimeMonitoringModel> devices = {};
|
||||
|
||||
if (event.snapshot.value != null) {
|
||||
|
|
@ -40,12 +39,16 @@ class RealtimeDatabaseService {
|
|||
.child('realtime/rooms/$roomId/devices/$deviceId')
|
||||
.onValue
|
||||
.map((event) {
|
||||
if (event.snapshot.value != null) {
|
||||
final data = event.snapshot.value as Map<dynamic, dynamic>;
|
||||
return RealtimeMonitoringModel.fromRealtimeDB(deviceId, roomId, data);
|
||||
}
|
||||
return null;
|
||||
});
|
||||
if (event.snapshot.value != null) {
|
||||
final data = event.snapshot.value as Map<dynamic, dynamic>;
|
||||
return RealtimeMonitoringModel.fromRealtimeDB(
|
||||
deviceId,
|
||||
roomId,
|
||||
data,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
Stream<List<RealtimeMonitoringModel>> getAllDevicesStream() {
|
||||
|
|
@ -83,8 +86,9 @@ class RealtimeDatabaseService {
|
|||
String deviceId,
|
||||
) async {
|
||||
try {
|
||||
final snapshot =
|
||||
await _database.child('realtime/rooms/$roomId/devices/$deviceId').get();
|
||||
final snapshot = await _database
|
||||
.child('realtime/rooms/$roomId/devices/$deviceId')
|
||||
.get();
|
||||
|
||||
if (snapshot.value != null) {
|
||||
final data = snapshot.value as Map<dynamic, dynamic>;
|
||||
|
|
@ -102,9 +106,9 @@ class RealtimeDatabaseService {
|
|||
await _database
|
||||
.child('realtime/rooms/$roomId/devices/$deviceId/controlling')
|
||||
.update({
|
||||
'servo_open': open,
|
||||
'last_command': DateTime.now().toIso8601String(),
|
||||
});
|
||||
'servo_open': open,
|
||||
'last_command': DateTime.now().toIso8601String(),
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> updateDropRate(
|
||||
|
|
@ -115,9 +119,9 @@ class RealtimeDatabaseService {
|
|||
await _database
|
||||
.child('realtime/rooms/$roomId/devices/$deviceId/monitoring')
|
||||
.update({
|
||||
'drop_rate': dropRate,
|
||||
'last_update': DateTime.now().toIso8601String(),
|
||||
});
|
||||
'drop_rate': dropRate,
|
||||
'last_update': DateTime.now().toIso8601String(),
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> updateDeviceStatus(
|
||||
|
|
@ -128,9 +132,9 @@ class RealtimeDatabaseService {
|
|||
await _database
|
||||
.child('realtime/rooms/$roomId/devices/$deviceId/monitoring')
|
||||
.update({
|
||||
'device_status': status,
|
||||
'last_update': DateTime.now().toIso8601String(),
|
||||
});
|
||||
'device_status': status,
|
||||
'last_update': DateTime.now().toIso8601String(),
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> updateServoSettings(
|
||||
|
|
@ -142,9 +146,9 @@ class RealtimeDatabaseService {
|
|||
await _database
|
||||
.child('realtime/rooms/$roomId/devices/$deviceId/settings')
|
||||
.update({
|
||||
'servo_angle_open': angleOpen,
|
||||
'servo_angle_close': angleClose,
|
||||
});
|
||||
'servo_angle_open': angleOpen,
|
||||
'servo_angle_close': angleClose,
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== UTILITY ====================
|
||||
|
|
@ -158,11 +162,25 @@ class RealtimeDatabaseService {
|
|||
}
|
||||
}
|
||||
|
||||
Future<void> controlBuzzer(
|
||||
String roomId,
|
||||
String deviceId,
|
||||
bool active,
|
||||
) async {
|
||||
await _database
|
||||
.child('realtime/rooms/$roomId/devices/$deviceId/controlling')
|
||||
.update({
|
||||
'buzzer_active': active,
|
||||
'last_command': DateTime.now().toIso8601String(),
|
||||
});
|
||||
}
|
||||
|
||||
Future<double> getDeviceDropRate(String roomId, String deviceId) async {
|
||||
try {
|
||||
final snapshot = await _database
|
||||
.child(
|
||||
'realtime/rooms/$roomId/devices/$deviceId/monitoring/drop_rate')
|
||||
'realtime/rooms/$roomId/devices/$deviceId/monitoring/drop_rate',
|
||||
)
|
||||
.get();
|
||||
|
||||
if (snapshot.value != null) {
|
||||
|
|
@ -173,4 +191,4 @@ class RealtimeDatabaseService {
|
|||
return 0.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,11 +37,10 @@ class HistoryGrafikCard extends StatelessWidget {
|
|||
);
|
||||
}
|
||||
|
||||
final hours = dataPoints.map((p) => p.hour.toDouble()).toList();
|
||||
final drops = dataPoints.map((p) => p.dropsPerMinute.toDouble()).toList();
|
||||
|
||||
final minX = hours.reduce((a, b) => a < b ? a : b);
|
||||
final maxX = hours.reduce((a, b) => a > b ? a : b);
|
||||
final minX = 0.0;
|
||||
final maxX = (dataPoints.length - 1).toDouble();
|
||||
final minY = (drops.reduce((a, b) => a < b ? a : b) - 5.0).clamp(
|
||||
0.0,
|
||||
double.infinity,
|
||||
|
|
@ -113,10 +112,14 @@ class HistoryGrafikCard extends StatelessWidget {
|
|||
reservedSize: 30,
|
||||
interval: dataPoints.length > 5 ? 2.0 : 1.0,
|
||||
getTitlesWidget: (double value, TitleMeta meta) {
|
||||
final index = value.toInt();
|
||||
if (index < 0 || index >= dataPoints.length) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 8.0),
|
||||
child: Text(
|
||||
'${value.toInt()}:00',
|
||||
dataPoints[index].timeLabel,
|
||||
style: TextStyle(
|
||||
color: Colors.grey[600],
|
||||
fontWeight: FontWeight.w500,
|
||||
|
|
@ -158,14 +161,12 @@ class HistoryGrafikCard extends StatelessWidget {
|
|||
maxY: maxY,
|
||||
lineBarsData: [
|
||||
LineChartBarData(
|
||||
spots: dataPoints
|
||||
.map(
|
||||
(point) => FlSpot(
|
||||
point.hour.toDouble(),
|
||||
point.dropsPerMinute.toDouble(),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
spots: List.generate(dataPoints.length, (index) {
|
||||
return FlSpot(
|
||||
index.toDouble(),
|
||||
dataPoints[index].dropsPerMinute.toDouble(),
|
||||
);
|
||||
}),
|
||||
isCurved: true,
|
||||
gradient: const LinearGradient(
|
||||
colors: [Color(0xFF2196F3), Color(0xFF1976D2)],
|
||||
|
|
@ -208,8 +209,12 @@ class HistoryGrafikCard extends StatelessWidget {
|
|||
tooltipMargin: 8,
|
||||
getTooltipItems: (List<LineBarSpot> touchedBarSpots) {
|
||||
return touchedBarSpots.map((barSpot) {
|
||||
final index = barSpot.x.toInt();
|
||||
final label = (index >= 0 && index < dataPoints.length)
|
||||
? dataPoints[index].timeLabel
|
||||
: '';
|
||||
return LineTooltipItem(
|
||||
'${barSpot.y.toInt()} tpm\n${barSpot.x.toInt()}:00',
|
||||
'${barSpot.y.toInt()} tpm\n$label',
|
||||
const TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
|
|
|
|||
|
|
@ -30,13 +30,17 @@ class _ModalScheduleAddEditState extends State<ModalScheduleAddEdit> {
|
|||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_medicineController = TextEditingController(text: widget.schedule?.medicineTime ?? '');
|
||||
_fluidController = TextEditingController(text: widget.schedule?.fluidTime ?? '');
|
||||
_medicineController = TextEditingController(
|
||||
text: widget.schedule?.medicineTime ?? '',
|
||||
);
|
||||
_fluidController = TextEditingController(
|
||||
text: widget.schedule?.fluidTime ?? '',
|
||||
);
|
||||
_selectedDate = DateTime.now();
|
||||
|
||||
if (widget.isEdit && widget.schedule != null) {
|
||||
_selectedPatientId = widget.schedule!.id;
|
||||
_selectedTime = widget.schedule!.timeOfDay;
|
||||
// _selectedPatientId akan diset setelah _loadPatients() selesai
|
||||
}
|
||||
|
||||
_loadPatients();
|
||||
|
|
@ -46,11 +50,16 @@ class _ModalScheduleAddEditState extends State<ModalScheduleAddEdit> {
|
|||
try {
|
||||
final controller = Get.find<ScheduleController>();
|
||||
final patientsList = await controller.getPatientsList();
|
||||
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_patients = patientsList;
|
||||
_isLoadingPatients = false;
|
||||
|
||||
// Set selected patient ID untuk mode edit
|
||||
if (widget.isEdit && widget.schedule != null) {
|
||||
_selectedPatientId = widget.schedule!.patientId;
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
|
|
@ -101,7 +110,9 @@ class _ModalScheduleAddEditState extends State<ModalScheduleAddEdit> {
|
|||
? const Center(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(40),
|
||||
child: CircularProgressIndicator(color: Color(0xFF0091EA)),
|
||||
child: CircularProgressIndicator(
|
||||
color: Color(0xFF0091EA),
|
||||
),
|
||||
),
|
||||
)
|
||||
: SingleChildScrollView(
|
||||
|
|
@ -140,7 +151,10 @@ class _ModalScheduleAddEditState extends State<ModalScheduleAddEdit> {
|
|||
},
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
_buildLabel('Detail Cairan & Tetes', Icons.water_drop),
|
||||
_buildLabel(
|
||||
'Detail Cairan & Tetes',
|
||||
Icons.water_drop,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_buildTextField(
|
||||
controller: _fluidController,
|
||||
|
|
@ -168,7 +182,9 @@ class _ModalScheduleAddEditState extends State<ModalScheduleAddEdit> {
|
|||
return Container(
|
||||
padding: const EdgeInsets.all(24),
|
||||
decoration: const BoxDecoration(
|
||||
gradient: LinearGradient(colors: [Color(0xFF0091EA), Color(0xFF0277BD)]),
|
||||
gradient: LinearGradient(
|
||||
colors: [Color(0xFF0091EA), Color(0xFF0277BD)],
|
||||
),
|
||||
borderRadius: BorderRadius.only(
|
||||
topLeft: Radius.circular(24),
|
||||
topRight: Radius.circular(24),
|
||||
|
|
@ -182,7 +198,11 @@ class _ModalScheduleAddEditState extends State<ModalScheduleAddEdit> {
|
|||
color: Colors.white.withOpacity(0.2),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: const Icon(Icons.calendar_today, color: Colors.white, size: 24),
|
||||
child: const Icon(
|
||||
Icons.calendar_today,
|
||||
color: Colors.white,
|
||||
size: 24,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
|
|
@ -258,16 +278,25 @@ class _ModalScheduleAddEditState extends State<ModalScheduleAddEdit> {
|
|||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.calendar_today, size: 18, color: Color(0xFF0091EA)),
|
||||
const Icon(
|
||||
Icons.calendar_today,
|
||||
size: 18,
|
||||
color: Color(0xFF0091EA),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
_selectedDate != null
|
||||
? DateFormat('EEEE, dd MMMM yyyy', 'id_ID').format(_selectedDate!)
|
||||
? DateFormat(
|
||||
'EEEE, dd MMMM yyyy',
|
||||
'id_ID',
|
||||
).format(_selectedDate!)
|
||||
: 'Pilih tanggal...',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: _selectedDate != null ? const Color(0xFF424242) : Colors.grey[400],
|
||||
color: _selectedDate != null
|
||||
? const Color(0xFF424242)
|
||||
: Colors.grey[400],
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
|
|
@ -296,6 +325,7 @@ class _ModalScheduleAddEditState extends State<ModalScheduleAddEdit> {
|
|||
|
||||
return DropdownButtonFormField<String>(
|
||||
value: _selectedPatientId,
|
||||
dropdownColor: Colors.white,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Cari dan pilih pasien...',
|
||||
hintStyle: TextStyle(color: Colors.grey[400], fontSize: 14),
|
||||
|
|
@ -317,7 +347,10 @@ class _ModalScheduleAddEditState extends State<ModalScheduleAddEdit> {
|
|||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: const BorderSide(color: Colors.redAccent),
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 14,
|
||||
),
|
||||
),
|
||||
icon: const Icon(Icons.arrow_drop_down, color: Color(0xFF0091EA)),
|
||||
isExpanded: true,
|
||||
|
|
@ -334,11 +367,13 @@ class _ModalScheduleAddEditState extends State<ModalScheduleAddEdit> {
|
|||
),
|
||||
);
|
||||
}).toList(),
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_selectedPatientId = value;
|
||||
});
|
||||
},
|
||||
onChanged: widget.isEdit
|
||||
? null
|
||||
: (value) {
|
||||
setState(() {
|
||||
_selectedPatientId = value;
|
||||
});
|
||||
},
|
||||
validator: (value) {
|
||||
if (value == null) {
|
||||
return 'Pilih pasien terlebih dahulu';
|
||||
|
|
@ -388,7 +423,10 @@ class _ModalScheduleAddEditState extends State<ModalScheduleAddEdit> {
|
|||
Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 10,
|
||||
vertical: 4,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFE3F2FD),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
|
|
@ -404,7 +442,10 @@ class _ModalScheduleAddEditState extends State<ModalScheduleAddEdit> {
|
|||
),
|
||||
const SizedBox(width: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 10,
|
||||
vertical: 4,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey[100],
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
|
|
@ -445,6 +486,7 @@ class _ModalScheduleAddEditState extends State<ModalScheduleAddEdit> {
|
|||
Widget _buildTimeDropdown() {
|
||||
return DropdownButtonFormField<String>(
|
||||
value: _selectedTime,
|
||||
dropdownColor: Colors.white,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Pilih waktu...',
|
||||
hintStyle: TextStyle(color: Colors.grey[400], fontSize: 14),
|
||||
|
|
@ -466,7 +508,10 @@ class _ModalScheduleAddEditState extends State<ModalScheduleAddEdit> {
|
|||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: const BorderSide(color: Colors.redAccent),
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 14,
|
||||
),
|
||||
),
|
||||
icon: const Icon(Icons.arrow_drop_down, color: Color(0xFF0091EA)),
|
||||
isExpanded: true,
|
||||
|
|
@ -561,7 +606,10 @@ class _ModalScheduleAddEditState extends State<ModalScheduleAddEdit> {
|
|||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: const BorderSide(color: Colors.redAccent, width: 2),
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 14,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
@ -584,7 +632,9 @@ class _ModalScheduleAddEditState extends State<ModalScheduleAddEdit> {
|
|||
style: OutlinedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
side: BorderSide(color: Colors.grey.shade300, width: 2),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
'Batal',
|
||||
|
|
@ -618,7 +668,9 @@ class _ModalScheduleAddEditState extends State<ModalScheduleAddEdit> {
|
|||
backgroundColor: Colors.transparent,
|
||||
shadowColor: Colors.transparent,
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
widget.isEdit ? 'Simpan' : 'Tambah',
|
||||
|
|
@ -649,19 +701,25 @@ class _ModalScheduleAddEditState extends State<ModalScheduleAddEdit> {
|
|||
}
|
||||
|
||||
final controller = Get.find<ScheduleController>();
|
||||
final patientData = _selectedPatientData!;
|
||||
|
||||
final schedule = PatientSchedule(
|
||||
id: _selectedPatientId!,
|
||||
name: _selectedPatientData!['name']!,
|
||||
room: _selectedPatientData!['room']!,
|
||||
deviceId: _selectedPatientData!['device']!,
|
||||
id: widget.isEdit ? widget.schedule!.id : '',
|
||||
patientId: _selectedPatientId!,
|
||||
name: patientData['name']!,
|
||||
room: patientData['room']!,
|
||||
deviceId: patientData['device']!,
|
||||
medicineTime: _medicineController.text.trim(),
|
||||
fluidTime: _fluidController.text.trim(),
|
||||
timeOfDay: _selectedTime!,
|
||||
);
|
||||
|
||||
if (widget.isEdit && widget.schedule != null) {
|
||||
controller.updateSchedule(widget.schedule!.id, schedule, _selectedDate!);
|
||||
controller.updateSchedule(
|
||||
widget.schedule!.id,
|
||||
schedule,
|
||||
_selectedDate!,
|
||||
);
|
||||
} else {
|
||||
controller.addSchedule(schedule, _selectedDate!);
|
||||
}
|
||||
|
|
@ -669,4 +727,4 @@ class _ModalScheduleAddEditState extends State<ModalScheduleAddEdit> {
|
|||
Get.back();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,11 +5,7 @@ class MonitoringCard extends StatelessWidget {
|
|||
final InfusMonitoring monitoring;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
const MonitoringCard({
|
||||
super.key,
|
||||
required this.monitoring,
|
||||
this.onTap,
|
||||
});
|
||||
const MonitoringCard({super.key, required this.monitoring, this.onTap});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
|
|
@ -158,11 +154,7 @@ class MonitoringCard extends StatelessWidget {
|
|||
const Color(0xFFE3F2FD),
|
||||
const Color(0xFF2196F3),
|
||||
),
|
||||
_buildBadge(
|
||||
monitoring.room,
|
||||
Colors.grey[100]!,
|
||||
Colors.grey[700]!,
|
||||
),
|
||||
_buildBadge(monitoring.room, Colors.grey[100]!, Colors.grey[700]!),
|
||||
_buildBadge(
|
||||
isConnected ? 'Online' : 'Offline',
|
||||
isConnected ? const Color(0xFFE8F5E9) : Colors.red[50]!,
|
||||
|
|
@ -196,4 +188,4 @@ class MonitoringCard extends StatelessWidget {
|
|||
if (diff.inHours < 24) return '${diff.inHours}h ago';
|
||||
return '${diff.inDays}d ago';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,9 +6,13 @@
|
|||
|
||||
#include "generated_plugin_registrant.h"
|
||||
|
||||
#include <awesome_notifications/awesome_notifications_plugin.h>
|
||||
#include <url_launcher_linux/url_launcher_plugin.h>
|
||||
|
||||
void fl_register_plugins(FlPluginRegistry* registry) {
|
||||
g_autoptr(FlPluginRegistrar) awesome_notifications_registrar =
|
||||
fl_plugin_registry_get_registrar_for_plugin(registry, "AwesomeNotificationsPlugin");
|
||||
awesome_notifications_plugin_register_with_registrar(awesome_notifications_registrar);
|
||||
g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar =
|
||||
fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin");
|
||||
url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar);
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
#
|
||||
|
||||
list(APPEND FLUTTER_PLUGIN_LIST
|
||||
awesome_notifications
|
||||
url_launcher_linux
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
import FlutterMacOS
|
||||
import Foundation
|
||||
|
||||
import awesome_notifications
|
||||
import cloud_firestore
|
||||
import firebase_auth
|
||||
import firebase_core
|
||||
|
|
@ -13,6 +14,7 @@ import path_provider_foundation
|
|||
import url_launcher_macos
|
||||
|
||||
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||
AwesomeNotificationsPlugin.register(with: registry.registrar(forPlugin: "AwesomeNotificationsPlugin"))
|
||||
FLTFirebaseFirestorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseFirestorePlugin"))
|
||||
FLTFirebaseAuthPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseAuthPlugin"))
|
||||
FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin"))
|
||||
|
|
|
|||
|
|
@ -17,6 +17,14 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.13.0"
|
||||
awesome_notifications:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: awesome_notifications
|
||||
sha256: "0d5fa4457f2ba4e536adc3ef6af709cdcecf4a05a1f3035981e9afa2f899b2a8"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.10.1"
|
||||
boolean_selector:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@ dependencies:
|
|||
firebase_auth: ^6.1.4
|
||||
firebase_database: ^12.1.2
|
||||
cloud_firestore: ^6.1.2
|
||||
awesome_notifications: ^0.10.1
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -6,12 +6,15 @@
|
|||
|
||||
#include "generated_plugin_registrant.h"
|
||||
|
||||
#include <awesome_notifications/awesome_notifications_plugin_c_api.h>
|
||||
#include <cloud_firestore/cloud_firestore_plugin_c_api.h>
|
||||
#include <firebase_auth/firebase_auth_plugin_c_api.h>
|
||||
#include <firebase_core/firebase_core_plugin_c_api.h>
|
||||
#include <url_launcher_windows/url_launcher_windows.h>
|
||||
|
||||
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
||||
AwesomeNotificationsPluginCApiRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("AwesomeNotificationsPluginCApi"));
|
||||
CloudFirestorePluginCApiRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("CloudFirestorePluginCApi"));
|
||||
FirebaseAuthPluginCApiRegisterWithRegistrar(
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
#
|
||||
|
||||
list(APPEND FLUTTER_PLUGIN_LIST
|
||||
awesome_notifications
|
||||
cloud_firestore
|
||||
firebase_auth
|
||||
firebase_core
|
||||
|
|
|
|||