first commit

This commit is contained in:
MohammadFerryAlfansyah 2026-07-29 23:50:22 +07:00
parent 314bcc3b59
commit 3300d82c8d
52 changed files with 1334 additions and 414 deletions

View File

@ -1,3 +1,4 @@
{
"java.configuration.updateBuildConfiguration": "automatic"
"java.configuration.updateBuildConfiguration": "automatic",
"cmake.sourceDirectory": "E:/JOKI/smartinfuse/linux"
}

View File

@ -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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 544 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 442 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 721 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

File diff suppressed because one or more lines are too long

View File

@ -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)';
}
}

View File

@ -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,

View File

@ -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,6 +188,17 @@ 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();
}

View File

@ -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',
@ -101,18 +110,6 @@ class DeviceDetailView extends GetView<DeviceDetailController> {
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),

View File

@ -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();
}
}

View File

@ -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),

View File

@ -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();
}
}

View File

@ -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);
}
}

View File

@ -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',

View File

@ -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());
}
}

View File

@ -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'),
],
),
);

View File

@ -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

View File

@ -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,
);
},

View File

@ -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},

View File

@ -6,6 +6,7 @@ import '../../../widgets/app_snackbar.dart';
class PatientSchedule {
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;
}

View File

@ -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() {
@ -14,14 +16,42 @@ 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);
}
}

View File

@ -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,

View File

@ -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();
}
}

View File

@ -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) {

View File

@ -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,

View File

@ -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();
@ -51,6 +55,11 @@ class _ModalScheduleAddEditState extends State<ModalScheduleAddEdit> {
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!);
}

View File

@ -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]!,

View File

@ -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);

View File

@ -3,6 +3,7 @@
#
list(APPEND FLUTTER_PLUGIN_LIST
awesome_notifications
url_launcher_linux
)

View File

@ -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"))

View File

@ -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:

View File

@ -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

View File

@ -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(

View File

@ -3,6 +3,7 @@
#
list(APPEND FLUTTER_PLUGIN_LIST
awesome_notifications
cloud_firestore
firebase_auth
firebase_core