From 314bcc3b598fb0ba97b4d9fd47c8f3af0f4d6fa8 Mon Sep 17 00:00:00 2001 From: er4files Date: Fri, 13 Feb 2026 00:34:43 +0700 Subject: [PATCH] fitur pasien home --- android/app/build.gradle | 64 ++ android/app/build.gradle.kts | 44 - android/app/src/main/AndroidManifest.xml | 12 +- android/build.gradle | 35 + android/build.gradle.kts | 24 - .../reports/problems/problems-report.html | 663 +++++++++++++++ android/settings.gradle.kts | 2 +- assets/images/logo.png | Bin 74388 -> 15667 bytes lib/app/models/chart_data_point.dart | 6 +- lib/app/models/device_model.dart | 32 + lib/app/models/history_model.dart | 63 ++ lib/app/models/notification_model.dart | 70 ++ lib/app/models/patient_model.dart | 44 + lib/app/models/realtime_monitoring_model.dart | 95 +++ lib/app/models/room_model.dart | 63 ++ lib/app/models/schedule_model.dart | 49 ++ lib/app/models/user_model.dart | 59 ++ .../controllers/device_detail_controller.dart | 185 ++++- .../views/device_detail_view.dart | 370 ++++++++- .../controllers/home_patient_controller.dart | 481 +++++++++-- .../home-patient/views/home_patient_view.dart | 447 ++++++---- .../home/controllers/home_controller.dart | 165 +++- lib/app/modules/home/views/home_view.dart | 181 ++-- .../login/controllers/login_controller.dart | 149 +++- lib/app/modules/login/views/login_view.dart | 190 ++--- .../controllers/notification_controller.dart | 174 ++-- .../controllers/profile_controller.dart | 395 ++++++++- .../modules/profile/views/profile_view.dart | 234 ++++-- .../controllers/schedule_controller.dart | 216 ++++- .../modules/schedule/views/schedule_view.dart | 457 ++++++----- .../controllers/splash_screen_controller.dart | 20 +- lib/app/services/firestore_service.dart | 467 +++++++++++ lib/app/services/notification_service.dart | 0 .../services/realtime_database_service.dart | 176 ++++ lib/app/widgets/app_snackbar.dart | 48 ++ lib/app/widgets/history_grafik_card.dart | 94 ++- lib/app/widgets/modal_patient_add_edit.dart | 649 +++++++++------ lib/app/widgets/modal_schedule_add_edit.dart | 771 +++++++++--------- lib/app/widgets/monitoring_card.dart | 315 +++---- lib/app/widgets/schedule_card.dart | 384 ++++----- lib/main.dart | 33 +- pubspec.yaml | 1 + 42 files changed, 5896 insertions(+), 2031 deletions(-) create mode 100644 android/app/build.gradle delete mode 100644 android/app/build.gradle.kts create mode 100644 android/build.gradle delete mode 100644 android/build.gradle.kts create mode 100644 android/build/reports/problems/problems-report.html create mode 100644 lib/app/models/device_model.dart create mode 100644 lib/app/models/history_model.dart create mode 100644 lib/app/models/notification_model.dart create mode 100644 lib/app/models/patient_model.dart create mode 100644 lib/app/models/realtime_monitoring_model.dart create mode 100644 lib/app/models/room_model.dart create mode 100644 lib/app/models/schedule_model.dart create mode 100644 lib/app/models/user_model.dart create mode 100644 lib/app/services/firestore_service.dart create mode 100644 lib/app/services/notification_service.dart create mode 100644 lib/app/services/realtime_database_service.dart create mode 100644 lib/app/widgets/app_snackbar.dart diff --git a/android/app/build.gradle b/android/app/build.gradle new file mode 100644 index 0000000..ba14f6b --- /dev/null +++ b/android/app/build.gradle @@ -0,0 +1,64 @@ +plugins { + id "com.android.application" + id "kotlin-android" + id "dev.flutter.flutter-gradle-plugin" +} + +// Load key.properties +def keystoreProperties = new Properties() +def keystorePropertiesFile = rootProject.file("key.properties") +if (keystorePropertiesFile.exists()) { + keystoreProperties.load(new FileInputStream(keystorePropertiesFile)) +} + +android { + namespace = "com.example.smartinfuse" + compileSdk = flutter.compileSdkVersion + ndkVersion = flutter.ndkVersion + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + + kotlinOptions { + jvmTarget = JavaVersion.VERSION_11 + } + + defaultConfig { + applicationId = "com.example.smartinfuse" + minSdk = flutter.minSdkVersion + targetSdk = flutter.targetSdkVersion + versionCode = flutter.versionCode + versionName = flutter.versionName + } + + signingConfigs { + debug { + // default debug keystore + } + + release { + if (keystorePropertiesFile.exists()) { + storeFile file(keystoreProperties['storeFile']) + storePassword keystoreProperties['storePassword'] + keyAlias keystoreProperties['keyAlias'] + keyPassword keystoreProperties['keyPassword'] + } + } + } + + buildTypes { + release { + signingConfig = signingConfigs.debug + } + } +} + +flutter { + source = "../.." +} + +dependencies {} + +apply plugin: 'com.google.gms.google-services' \ No newline at end of file diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts deleted file mode 100644 index f04f562..0000000 --- a/android/app/build.gradle.kts +++ /dev/null @@ -1,44 +0,0 @@ -plugins { - id("com.android.application") - id("kotlin-android") - // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. - id("dev.flutter.flutter-gradle-plugin") -} - -android { - namespace = "com.example.smartinfuse" - compileSdk = flutter.compileSdkVersion - ndkVersion = flutter.ndkVersion - - compileOptions { - sourceCompatibility = JavaVersion.VERSION_11 - targetCompatibility = JavaVersion.VERSION_11 - } - - kotlinOptions { - jvmTarget = JavaVersion.VERSION_11.toString() - } - - defaultConfig { - // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). - applicationId = "com.example.smartinfuse" - // You can update the following values to match your application needs. - // For more information, see: https://flutter.dev/to/review-gradle-config. - minSdk = flutter.minSdkVersion - targetSdk = flutter.targetSdkVersion - versionCode = flutter.versionCode - versionName = flutter.versionName - } - - buildTypes { - release { - // TODO: Add your own signing config for the release build. - // Signing with the debug keys for now, so `flutter run --release` works. - signingConfig = signingConfigs.getByName("debug") - } - } -} - -flutter { - source = "../.." -} diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 8125d18..ddad42f 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -41,5 +41,15 @@ + + + + + + + + + + - + \ No newline at end of file diff --git a/android/build.gradle b/android/build.gradle new file mode 100644 index 0000000..6e70052 --- /dev/null +++ b/android/build.gradle @@ -0,0 +1,35 @@ +buildscript { + ext.kotlin_version = '2.1.0' + + repositories { + google() + mavenCentral() + } + + dependencies { + classpath "com.android.tools.build:gradle:8.3.0" + classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" + classpath 'com.google.gms:google-services:4.3.15' + } +} + +allprojects { + repositories { + google() + mavenCentral() + } +} + +rootProject.buildDir = '../build' + +subprojects { + project.buildDir = "${rootProject.buildDir}/${project.name}" +} + +subprojects { + project.evaluationDependsOn(':app') +} + +tasks.register("clean", Delete) { + delete rootProject.buildDir +} \ No newline at end of file diff --git a/android/build.gradle.kts b/android/build.gradle.kts deleted file mode 100644 index dbee657..0000000 --- a/android/build.gradle.kts +++ /dev/null @@ -1,24 +0,0 @@ -allprojects { - repositories { - google() - mavenCentral() - } -} - -val newBuildDir: Directory = - rootProject.layout.buildDirectory - .dir("../../build") - .get() -rootProject.layout.buildDirectory.value(newBuildDir) - -subprojects { - val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name) - project.layout.buildDirectory.value(newSubprojectBuildDir) -} -subprojects { - project.evaluationDependsOn(":app") -} - -tasks.register("clean") { - delete(rootProject.layout.buildDirectory) -} diff --git a/android/build/reports/problems/problems-report.html b/android/build/reports/problems/problems-report.html new file mode 100644 index 0000000..c667740 --- /dev/null +++ b/android/build/reports/problems/problems-report.html @@ -0,0 +1,663 @@ + + + + + + + + + + + + + Gradle Configuration Cache + + + +
+ +
+ Loading... +
+ + + + + + diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts index fb605bc..3eb433c 100644 --- a/android/settings.gradle.kts +++ b/android/settings.gradle.kts @@ -20,7 +20,7 @@ pluginManagement { plugins { id("dev.flutter.flutter-plugin-loader") version "1.0.0" id("com.android.application") version "8.9.1" apply false - id("org.jetbrains.kotlin.android") version "2.1.0" apply false + id ("org.jetbrains.kotlin.android") version "1.9.24" apply false } include(":app") diff --git a/assets/images/logo.png b/assets/images/logo.png index ce5af919bedf2773e9f5085cd3acd4aef6bd6152..999fe8cb46da273adc76a7006733571043c672a1 100644 GIT binary patch delta 12202 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 N!F|@mZ1dAU{|_K|%oP9t literal 74388 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�E4Lsa4wo1zrj%n`N1jhsGg=<93U=U9 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

QKD-1wK=o0E)JhwMT%Zr!|8I16`PB{dqd(g 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 diff --git a/lib/app/models/chart_data_point.dart b/lib/app/models/chart_data_point.dart index 5f97303..27d0e52 100644 --- a/lib/app/models/chart_data_point.dart +++ b/lib/app/models/chart_data_point.dart @@ -1,4 +1,3 @@ -// lib/app/models/chart_data_point.dart class ChartDataPoint { final int hour; final double dropsPerMinute; @@ -7,4 +6,9 @@ class ChartDataPoint { required this.hour, required this.dropsPerMinute, }); + + @override + String toString() { + return 'ChartDataPoint(hour: $hour, dropsPerMinute: $dropsPerMinute)'; + } } \ No newline at end of file diff --git a/lib/app/models/device_model.dart b/lib/app/models/device_model.dart new file mode 100644 index 0000000..fcb67cd --- /dev/null +++ b/lib/app/models/device_model.dart @@ -0,0 +1,32 @@ +// lib/app/models/device_model.dart +import 'package:cloud_firestore/cloud_firestore.dart'; + +class DeviceModel { + final String idDevices; + final String roomId; + final String devicesName; + + DeviceModel({ + required this.idDevices, + required this.roomId, + required this.devicesName, + }); + + // From Firestore + factory DeviceModel.fromFirestore(DocumentSnapshot doc) { + final data = doc.data() as Map; + return DeviceModel( + idDevices: doc.id, + roomId: data['room_id'] ?? '', + devicesName: data['devices_name'] ?? '', + ); + } + + // To Firestore + Map toFirestore() { + return { + 'room_id': roomId, + 'devices_name': devicesName, + }; + } +} \ No newline at end of file diff --git a/lib/app/models/history_model.dart b/lib/app/models/history_model.dart new file mode 100644 index 0000000..9d6dba8 --- /dev/null +++ b/lib/app/models/history_model.dart @@ -0,0 +1,63 @@ +import 'package:cloud_firestore/cloud_firestore.dart'; + +class HistoryModel { + final String id; + final String deviceId; + final double dropPerMinute; + final DateTime timestamp; + + HistoryModel({ + required this.id, + required this.deviceId, + required this.dropPerMinute, + required this.timestamp, + }); + + factory HistoryModel.fromFirestore(DocumentSnapshot doc) { + final data = doc.data() as Map; + return HistoryModel( + id: doc.id, + deviceId: data['device_id'] ?? '', + dropPerMinute: (data['drop_per_menit'] ?? 0).toDouble(), + timestamp: (data['timestamp'] as Timestamp).toDate(), + ); + } + + Map toFirestore() { + return { + 'device_id': deviceId, + 'drop_per_menit': dropPerMinute, + 'timestamp': Timestamp.fromDate(timestamp), + }; + } + + factory HistoryModel.fromMap(String id, Map data) { + return HistoryModel( + id: id, + deviceId: data['device_id'] ?? '', + dropPerMinute: (data['drop_per_menit'] ?? 0).toDouble(), + timestamp: data['timestamp'] is Timestamp + ? (data['timestamp'] as Timestamp).toDate() + : DateTime.parse(data['timestamp']), + ); + } + + HistoryModel copyWith({ + String? id, + String? deviceId, + double? dropPerMinute, + DateTime? timestamp, + }) { + return HistoryModel( + id: id ?? this.id, + deviceId: deviceId ?? this.deviceId, + dropPerMinute: dropPerMinute ?? this.dropPerMinute, + timestamp: timestamp ?? this.timestamp, + ); + } + + @override + String toString() { + return 'HistoryModel(id: $id, deviceId: $deviceId, dropPerMinute: $dropPerMinute, timestamp: $timestamp)'; + } +} \ No newline at end of file diff --git a/lib/app/models/notification_model.dart b/lib/app/models/notification_model.dart new file mode 100644 index 0000000..45b332b --- /dev/null +++ b/lib/app/models/notification_model.dart @@ -0,0 +1,70 @@ +// lib/app/models/notification_model.dart +import 'package:cloud_firestore/cloud_firestore.dart'; + +class NotificationModel { + final String id; + final String patientId; + final String deviceId; + final String title; + final String message; + final bool isRead; + final DateTime createdAt; + + NotificationModel({ + required this.id, + required this.patientId, + required this.deviceId, + required this.title, + required this.message, + this.isRead = false, + required this.createdAt, + }); + + // From Firestore + factory NotificationModel.fromFirestore(DocumentSnapshot doc) { + final data = doc.data() as Map; + return NotificationModel( + id: doc.id, + patientId: data['patient_id'] ?? '', + deviceId: data['device_id'] ?? '', + title: data['title'] ?? '', + message: data['message'] ?? '', + isRead: data['is_read'] ?? false, + createdAt: data['created_at'] != null + ? (data['created_at'] as Timestamp).toDate() + : DateTime.now(), + ); + } + + // To Firestore + Map toFirestore() { + return { + 'patient_id': patientId, + 'device_id': deviceId, + 'title': title, + 'message': message, + 'is_read': isRead, + 'created_at': Timestamp.fromDate(createdAt), + }; + } + + NotificationModel copyWith({ + String? id, + String? patientId, + String? deviceId, + String? title, + String? message, + bool? isRead, + DateTime? createdAt, + }) { + return NotificationModel( + id: id ?? this.id, + patientId: patientId ?? this.patientId, + deviceId: deviceId ?? this.deviceId, + title: title ?? this.title, + message: message ?? this.message, + isRead: isRead ?? this.isRead, + createdAt: createdAt ?? this.createdAt, + ); + } +} \ No newline at end of file diff --git a/lib/app/models/patient_model.dart b/lib/app/models/patient_model.dart new file mode 100644 index 0000000..1517663 --- /dev/null +++ b/lib/app/models/patient_model.dart @@ -0,0 +1,44 @@ +// lib/app/models/patient_model.dart +import 'package:cloud_firestore/cloud_firestore.dart'; + +class PatientModel { + final String id; + final String namePatient; + final String nameGuardian; + final String deviceId; + final String roomId; + final String? userId; // Firebase Auth UID + + PatientModel({ + required this.id, + required this.namePatient, + required this.nameGuardian, + required this.deviceId, + required this.roomId, + this.userId, + }); + + // From Firestore + factory PatientModel.fromFirestore(DocumentSnapshot doc) { + final data = doc.data() as Map; + return PatientModel( + id: doc.id, + namePatient: data['name_patient'] ?? '', + nameGuardian: data['name_guardian'] ?? '', + deviceId: data['device_id'] ?? '', + roomId: data['room_id'] ?? '', + userId: data['user_id'], + ); + } + + // To Firestore + Map toFirestore() { + return { + 'name_patient': namePatient, + 'name_guardian': nameGuardian, + 'device_id': deviceId, + 'room_id': roomId, + if (userId != null) 'user_id': userId, + }; + } +} \ No newline at end of file diff --git a/lib/app/models/realtime_monitoring_model.dart b/lib/app/models/realtime_monitoring_model.dart new file mode 100644 index 0000000..452f7ca --- /dev/null +++ b/lib/app/models/realtime_monitoring_model.dart @@ -0,0 +1,95 @@ +// lib/app/models/realtime_monitoring_model.dart + +class RealtimeMonitoringModel { + final String deviceId; + final String roomId; + final bool servoOpen; + final DateTime lastCommand; + final String deviceStatus; + final double dropRate; + final DateTime lastUpdate; + final int servoAngleOpen; + final int servoAngleClose; + + RealtimeMonitoringModel({ + required this.deviceId, + required this.roomId, + required this.servoOpen, + required this.lastCommand, + required this.deviceStatus, + required this.dropRate, + required this.lastUpdate, + this.servoAngleOpen = 90, + this.servoAngleClose = 0, + }); + + // From Realtime Database + factory RealtimeMonitoringModel.fromRealtimeDB( + String deviceId, + String roomId, + Map data, + ) { + final controlling = data['controlling'] as Map? ?? {}; + final monitoring = data['monitoring'] as Map? ?? {}; + final settings = data['settings'] as Map? ?? {}; + + return RealtimeMonitoringModel( + deviceId: deviceId, + roomId: roomId, + servoOpen: controlling['servo_open'] ?? false, + lastCommand: DateTime.parse( + controlling['last_command'] ?? DateTime.now().toIso8601String(), + ), + deviceStatus: monitoring['device_status'] ?? 'disconnected', + dropRate: (monitoring['drop_rate'] ?? 0).toDouble(), + lastUpdate: DateTime.parse( + monitoring['last_update'] ?? DateTime.now().toIso8601String(), + ), + servoAngleOpen: settings['servo_angle_open'] ?? 90, + servoAngleClose: settings['servo_angle_close'] ?? 0, + ); + } + + // To Realtime Database + Map toRealtimeDB() { + return { + 'controlling': { + 'servo_open': servoOpen, + 'last_command': lastCommand.toIso8601String(), + }, + 'monitoring': { + 'device_status': deviceStatus, + 'drop_rate': dropRate, + 'last_update': lastUpdate.toIso8601String(), + }, + 'settings': { + 'servo_angle_open': servoAngleOpen, + 'servo_angle_close': servoAngleClose, + }, + }; + } + + RealtimeMonitoringModel copyWith({ + String? deviceId, + String? roomId, + bool? servoOpen, + DateTime? lastCommand, + String? deviceStatus, + double? dropRate, + DateTime? lastUpdate, + int? servoAngleOpen, + int? servoAngleClose, + }) { + return RealtimeMonitoringModel( + deviceId: deviceId ?? this.deviceId, + roomId: roomId ?? this.roomId, + servoOpen: servoOpen ?? this.servoOpen, + lastCommand: lastCommand ?? this.lastCommand, + deviceStatus: deviceStatus ?? this.deviceStatus, + dropRate: dropRate ?? this.dropRate, + lastUpdate: lastUpdate ?? this.lastUpdate, + servoAngleOpen: servoAngleOpen ?? this.servoAngleOpen, + servoAngleClose: servoAngleClose ?? this.servoAngleClose, + ); + } +} \ No newline at end of file diff --git a/lib/app/models/room_model.dart b/lib/app/models/room_model.dart new file mode 100644 index 0000000..3e61348 --- /dev/null +++ b/lib/app/models/room_model.dart @@ -0,0 +1,63 @@ +import 'package:cloud_firestore/cloud_firestore.dart'; + +class RoomModel { + final String id; + final String roomName; + final int capacity; + final DateTime createdAt; + + RoomModel({ + required this.id, + required this.roomName, + required this.capacity, + required this.createdAt, + }); + + factory RoomModel.fromFirestore(DocumentSnapshot doc) { + final data = doc.data() as Map; + return RoomModel( + id: doc.id, + roomName: data['room_name'] ?? '', + capacity: data['capacity'] ?? 0, + createdAt: (data['created_at'] as Timestamp).toDate(), + ); + } + + Map toFirestore() { + return { + 'room_name': roomName, + 'capacity': capacity, + 'created_at': Timestamp.fromDate(createdAt), + }; + } + + factory RoomModel.fromMap(String id, Map data) { + return RoomModel( + id: id, + roomName: data['room_name'] ?? '', + capacity: data['capacity'] ?? 0, + createdAt: data['created_at'] is Timestamp + ? (data['created_at'] as Timestamp).toDate() + : DateTime.parse(data['created_at']), + ); + } + + RoomModel copyWith({ + String? id, + String? roomName, + int? capacity, + DateTime? createdAt, + }) { + return RoomModel( + id: id ?? this.id, + roomName: roomName ?? this.roomName, + capacity: capacity ?? this.capacity, + createdAt: createdAt ?? this.createdAt, + ); + } + + @override + String toString() { + return 'RoomModel(id: $id, roomName: $roomName, capacity: $capacity, createdAt: $createdAt)'; + } +} \ No newline at end of file diff --git a/lib/app/models/schedule_model.dart b/lib/app/models/schedule_model.dart new file mode 100644 index 0000000..05a8261 --- /dev/null +++ b/lib/app/models/schedule_model.dart @@ -0,0 +1,49 @@ +import 'package:cloud_firestore/cloud_firestore.dart'; + +class ScheduleModel { + final String id; + final String patientId; + final String medicineDetail; + final String fluidDetail; + final DateTime scheduleDate; + final String timeOfDay; + final DateTime createdAt; + + ScheduleModel({ + required this.id, + required this.patientId, + required this.medicineDetail, + required this.fluidDetail, + required this.scheduleDate, + required this.timeOfDay, + required this.createdAt, + }); + + factory ScheduleModel.fromFirestore(DocumentSnapshot doc) { + final data = doc.data() as Map; + return ScheduleModel( + id: doc.id, + patientId: data['patient_id'] ?? '', + medicineDetail: data['medicine_detail'] ?? '', + fluidDetail: data['fluid_detail'] ?? '', + scheduleDate: data['schedule_date'] != null + ? (data['schedule_date'] as Timestamp).toDate() + : DateTime.now(), + timeOfDay: data['time_of_day'] ?? 'Pagi', + createdAt: data['created_at'] != null + ? (data['created_at'] as Timestamp).toDate() + : DateTime.now(), + ); + } + + Map toFirestore() { + return { + 'patient_id': patientId, + 'medicine_detail': medicineDetail, + 'fluid_detail': fluidDetail, + 'schedule_date': Timestamp.fromDate(scheduleDate), + 'time_of_day': timeOfDay, + 'created_at': Timestamp.fromDate(createdAt), + }; + } +} \ No newline at end of file diff --git a/lib/app/models/user_model.dart b/lib/app/models/user_model.dart new file mode 100644 index 0000000..5de2aee --- /dev/null +++ b/lib/app/models/user_model.dart @@ -0,0 +1,59 @@ +import 'package:cloud_firestore/cloud_firestore.dart'; + +class UserModel { + final String id; + final String email; + final String name; + final String role; + final String? deviceId; + final DateTime? createdAt; + + UserModel({ + required this.id, + required this.email, + required this.name, + required this.role, + this.deviceId, + this.createdAt, + }); + + Map toFirestore() { + return { + 'email': email, + 'name': name, + 'role': role, + if (deviceId != null) 'device_id': deviceId, + 'created_at': createdAt ?? FieldValue.serverTimestamp(), + }; + } + + factory UserModel.fromFirestore(DocumentSnapshot doc) { + final data = doc.data() as Map; + return UserModel( + id: doc.id, + email: data['email'] ?? '', + name: data['name'] ?? '', + role: data['role'] ?? '', + deviceId: data['device_id'], + createdAt: (data['created_at'] as Timestamp?)?.toDate(), + ); + } + + UserModel copyWith({ + String? id, + String? email, + String? name, + String? role, + String? deviceId, + DateTime? createdAt, + }) { + return UserModel( + id: id ?? this.id, + email: email ?? this.email, + name: name ?? this.name, + role: role ?? this.role, + deviceId: deviceId ?? this.deviceId, + createdAt: createdAt ?? this.createdAt, + ); + } +} diff --git a/lib/app/modules/device-detail/controllers/device_detail_controller.dart b/lib/app/modules/device-detail/controllers/device_detail_controller.dart index ce58342..8c27953 100644 --- a/lib/app/modules/device-detail/controllers/device_detail_controller.dart +++ b/lib/app/modules/device-detail/controllers/device_detail_controller.dart @@ -1,23 +1,192 @@ import 'package:get/get.dart'; +import 'dart:async'; +import '../../../models/patient_model.dart'; +import '../../../models/device_model.dart'; +import '../../../models/realtime_monitoring_model.dart'; +import '../../../models/history_model.dart'; +import '../../../models/chart_data_point.dart'; +import '../../../services/firestore_service.dart'; +import '../../../services/realtime_database_service.dart'; +import '../../../widgets/app_snackbar.dart'; class DeviceDetailController extends GetxController { - //TODO: Implement DeviceDetailController + final FirestoreService _firestoreService = FirestoreService(); + final RealtimeDatabaseService _realtimeService = RealtimeDatabaseService(); + + String? deviceId; + String? roomId; + + final deviceName = ''.obs; + final patientName = ''.obs; + final roomName = ''.obs; + final dropsPerMinute = '0'.obs; + final lastUpdate = DateTime.now().obs; + final isServoActive = false.obs; + final deviceStatus = 'disconnected'.obs; + final isLoading = true.obs; + + final RxList chartData = [].obs; + + StreamSubscription? _realtimeSubscription; + StreamSubscription? _historySubscription; + + PatientModel? patient; + DeviceModel? device; + RealtimeMonitoringModel? realtimeData; - final count = 0.obs; @override void onInit() { super.onInit(); - } - - @override - void onReady() { - super.onReady(); + _getArguments(); + if (deviceId != null && roomId != null) { + _initializeDeviceDetail(); + } else { + Get.back(); + AppSnackbar.error('Device ID tidak ditemukan'); + } } @override void onClose() { + _realtimeSubscription?.cancel(); + _historySubscription?.cancel(); super.onClose(); } - void increment() => count.value++; + void _getArguments() { + final args = Get.arguments as Map?; + if (args != null) { + deviceId = args['deviceId']; + roomId = args['roomId']; + } + } + + Future _initializeDeviceDetail() async { + try { + isLoading.value = true; + + await _loadPatientData(); + await _loadDeviceData(); + _listenToRealtimeData(); + _listenToHistoryData(); + + isLoading.value = false; + } catch (e) { + isLoading.value = false; + AppSnackbar.error('Gagal memuat data'); + } + } + + Future _loadPatientData() async { + patient = await _firestoreService.getPatientByDeviceId(deviceId!); + if (patient != null) { + patientName.value = patient!.namePatient; + } + } + + Future _loadDeviceData() async { + device = await _firestoreService.getDeviceById(deviceId!); + if (device != null) { + deviceName.value = device!.devicesName; + roomName.value = await _getRoomName(device!.roomId); + } + } + + Future _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; + } + } + + void _listenToRealtimeData() { + final dbRoomId = roomId!.toLowerCase().replaceAll(' ', '_'); + + _realtimeSubscription = _realtimeService + .getDeviceStream(dbRoomId, deviceId!) + .listen((data) { + if (data != null) { + realtimeData = data; + dropsPerMinute.value = data.dropRate.toStringAsFixed(0); + lastUpdate.value = data.lastUpdate; + isServoActive.value = data.servoOpen; + deviceStatus.value = data.deviceStatus; + } + }, onError: (error) => AppSnackbar.error('Koneksi realtime terputus')); + } + + void _listenToHistoryData() { + _historySubscription = _firestoreService + .getDeviceHistoriesStream(deviceId!) + .listen( + (histories) { + if (histories.isNotEmpty) { + _processHistoryData(histories); + } else { + chartData.value = []; + } + }, + onError: (error) { + chartData.value = []; + AppSnackbar.error('Gagal memuat data history'); + }, + ); + } + + void _processHistoryData(List histories) { + histories.sort((a, b) => b.timestamp.compareTo(a.timestamp)); + + final latestHistories = histories.take(10).toList(); + + latestHistories.sort((a, b) => a.timestamp.compareTo(b.timestamp)); + + final List points = latestHistories.map((history) { + return ChartDataPoint( + hour: history.timestamp.hour, + dropsPerMinute: history.dropPerMinute, + ); + }).toList(); + + chartData.value = points; + } + + Future toggleServo(bool value) async { + try { + final dbRoomId = roomId!.toLowerCase().replaceAll(' ', '_'); + await _realtimeService.controlServo(dbRoomId, deviceId!, value); + isServoActive.value = value; + AppSnackbar.success(value ? 'Infus dibuka' : 'Infus ditutup'); + } catch (e) { + AppSnackbar.error('Gagal mengontrol servo'); + } + } + + String getLastUpdateText() { + final difference = DateTime.now().difference(lastUpdate.value); + if (difference.inMinutes < 1) return 'just now'; + if (difference.inMinutes < 60) return '${difference.inMinutes}m ago'; + if (difference.inHours < 24) return '${difference.inHours}h ago'; + return '${difference.inDays}d ago'; + } + + void navigateToHistory() { + Get.toNamed( + '/history', + arguments: { + 'deviceId': deviceId, + 'deviceName': deviceName.value, + 'patientName': patientName.value, + }, + ); + } + + Future refreshData() async { + await _loadDeviceData(); + } } diff --git a/lib/app/modules/device-detail/views/device_detail_view.dart b/lib/app/modules/device-detail/views/device_detail_view.dart index 0ed9285..9921e9e 100644 --- a/lib/app/modules/device-detail/views/device_detail_view.dart +++ b/lib/app/modules/device-detail/views/device_detail_view.dart @@ -1,22 +1,376 @@ import 'package:flutter/material.dart'; - import 'package:get/get.dart'; - +import '../../../widgets/history_grafik_card.dart'; import '../controllers/device_detail_controller.dart'; class DeviceDetailView extends GetView { const DeviceDetailView({super.key}); + @override Widget build(BuildContext context) { return Scaffold( + backgroundColor: Colors.grey[50], appBar: AppBar( - title: const Text('DeviceDetailView'), - centerTitle: true, + backgroundColor: Colors.white, + elevation: 0, + leading: IconButton( + icon: const Icon(Icons.arrow_back, color: Colors.black), + onPressed: () => Get.back(), + ), + title: const Text( + 'Detail Alat', + style: TextStyle( + color: Colors.black, + fontSize: 18, + fontWeight: FontWeight.w600, + ), + ), + actions: [ + IconButton( + icon: const Icon(Icons.refresh, color: Colors.black), + onPressed: controller.refreshData, + ), + ], ), - body: const Center( - child: Text( - 'DeviceDetailView is working', - style: TextStyle(fontSize: 20), + body: Obx(() { + if (controller.isLoading.value) { + return const Center( + child: CircularProgressIndicator(color: Color(0xFF2196F3)), + ); + } + + return RefreshIndicator( + onRefresh: controller.refreshData, + color: const Color(0xFF2196F3), + child: SingleChildScrollView( + physics: const AlwaysScrollableScrollPhysics(), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildSection( + title: 'Monitoring Infus', + onSeeAll: () {}, + child: _buildMonitoringCard(), + ), + const SizedBox(height: 20), + _buildSection( + title: 'Kontrol Alat', + onSeeAll: () {}, + child: _buildControlCard(), + ), + const SizedBox(height: 20), + _buildSection( + title: 'History Data', + onSeeAll: controller.navigateToHistory, + child: Obx(() { + if (controller.chartData.isEmpty) { + return _buildEmptyChart(); + } + return HistoryGrafikCard( + dataPoints: controller.chartData.value, + ); + }), + ), + const SizedBox(height: 20), + ], + ), + ), + ); + }), + ); + } + + Widget _buildSection({ + required String title, + required Widget child, + VoidCallback? onSeeAll, + }) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(20, 16, 20, 12), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + title, + style: const TextStyle( + fontSize: 16, + 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, + ), + ), + ), + ], + ), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: child, + ), + ], + ); + } + + Widget _buildMonitoringCard() { + return Obx( + () => Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: Colors.grey[200]!), + boxShadow: [ + BoxShadow( + color: Colors.grey.withOpacity(0.08), + blurRadius: 12, + offset: const Offset(0, 2), + ), + ], + ), + child: Row( + children: [ + Stack( + children: [ + Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: const Color(0xFFE3F2FD), + borderRadius: BorderRadius.circular(14), + ), + child: const Icon( + Icons.water_drop_outlined, + size: 32, + color: Color(0xFF2196F3), + ), + ), + Positioned( + right: 0, + top: 0, + child: Container( + width: 12, + height: 12, + decoration: BoxDecoration( + color: controller.deviceStatus.value == 'connected' + ? const Color(0xFF4CAF50) + : Colors.red, + shape: BoxShape.circle, + border: Border.all(color: Colors.white, width: 2), + ), + ), + ), + ], + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + controller.dropsPerMinute.value, + style: const TextStyle( + fontSize: 22, + fontWeight: FontWeight.bold, + color: Color(0xFF2196F3), + height: 1, + ), + ), + const SizedBox(width: 6), + const Padding( + padding: EdgeInsets.only(bottom: 2), + child: Text( + 'tetes per menit', + style: TextStyle( + fontSize: 12, + color: Colors.black54, + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ), + const SizedBox(height: 6), + Text( + 'Last update: ${controller.getLastUpdateText()}', + style: TextStyle(fontSize: 11, color: Colors.grey[600]), + ), + const SizedBox(height: 12), + if (controller.patientName.value.isNotEmpty) + RichText( + text: TextSpan( + style: TextStyle(fontSize: 12, color: Colors.grey[700]), + children: [ + const TextSpan( + text: 'Nama Pasien: ', + style: TextStyle(fontWeight: FontWeight.w500), + ), + TextSpan( + text: controller.patientName.value, + style: const TextStyle( + fontWeight: FontWeight.w600, + color: Colors.black87, + ), + ), + ], + ), + ), + const SizedBox(height: 8), + Wrap( + spacing: 8, + runSpacing: 6, + children: [ + if (controller.deviceId != null) + _buildBadge( + controller.deviceId!, + const Color(0xFFE3F2FD), + const Color(0xFF2196F3), + ), + if (controller.roomName.value.isNotEmpty) + _buildBadge( + controller.roomName.value, + Colors.grey[100]!, + Colors.grey[700]!, + ), + _buildBadge( + controller.deviceStatus.value == 'connected' + ? 'Online' + : 'Offline', + controller.deviceStatus.value == 'connected' + ? const Color(0xFFE8F5E9) + : Colors.red[50]!, + controller.deviceStatus.value == 'connected' + ? const Color(0xFF2E7D32) + : Colors.red[700]!, + ), + ], + ), + ], + ), + ), + ], + ), + ), + ); + } + + Widget _buildControlCard() { + return Obx( + () => Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: Colors.grey[200]!), + boxShadow: [ + BoxShadow( + color: Colors.grey.withOpacity(0.08), + blurRadius: 12, + offset: const Offset(0, 2), + ), + ], + ), + child: Row( + children: [ + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: const Color(0xFFE3F2FD), + borderRadius: BorderRadius.circular(12), + ), + child: const Icon( + Icons.settings_input_component, + size: 28, + color: Color(0xFF2196F3), + ), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Kontrol Servo', + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.w600, + color: Colors.black87, + ), + ), + const SizedBox(height: 4), + Text( + controller.isServoActive.value + ? 'Infus sedang mengalir' + : 'Infus ditutup', + style: TextStyle(fontSize: 12, color: Colors.grey[600]), + ), + ], + ), + ), + Switch( + value: controller.isServoActive.value, + onChanged: controller.deviceStatus.value == 'connected' + ? controller.toggleServo + : null, + activeColor: const Color(0xFF2196F3), + activeTrackColor: const Color(0xFFBBDEFB), + ), + ], + ), + ), + ); + } + + Widget _buildEmptyChart() { + return Container( + padding: const EdgeInsets.all(32), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.grey[200]!), + ), + child: Center( + child: Column( + children: [ + Icon(Icons.show_chart, size: 48, color: Colors.grey[400]), + const SizedBox(height: 12), + Text( + 'Belum ada data history', + style: TextStyle(fontSize: 14, color: Colors.grey[600]), + ), + ], + ), + ), + ); + } + + Widget _buildBadge(String text, Color bgColor, Color textColor) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), + decoration: BoxDecoration( + color: bgColor, + borderRadius: BorderRadius.circular(6), + ), + child: Text( + text, + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: textColor, ), ), ); diff --git a/lib/app/modules/home-patient/controllers/home_patient_controller.dart b/lib/app/modules/home-patient/controllers/home_patient_controller.dart index 5e25ab0..0ac39a6 100644 --- a/lib/app/modules/home-patient/controllers/home_patient_controller.dart +++ b/lib/app/modules/home-patient/controllers/home_patient_controller.dart @@ -1,92 +1,91 @@ -// lib/app/modules/home_patient/controllers/home_patient_controller.dart import 'package:get/get.dart'; import 'package:flutter/material.dart'; -import '../../../models/chart_data_point.dart'; // Import model yang terpisah +import 'package:firebase_auth/firebase_auth.dart'; +import 'package:url_launcher/url_launcher.dart'; +import 'dart:async'; +import '../../../services/firestore_service.dart'; +import '../../../services/realtime_database_service.dart'; +import '../../../models/patient_model.dart'; +import '../../../models/chart_data_point.dart'; +import '../../../models/history_model.dart'; +import '../../../models/notification_model.dart'; +import '../../../models/realtime_monitoring_model.dart'; +import '../../../widgets/app_snackbar.dart'; class InfusData { final String dropsPerMinute; final String room; final String deviceId; final String updateTime; + final String deviceStatus; InfusData({ required this.dropsPerMinute, required this.room, required this.deviceId, required this.updateTime, + required this.deviceStatus, }); } class NotificationData { + final String id; final String title; final String message; final String room; final String deviceId; final String timeAgo; + final DateTime createdAt; NotificationData({ + required this.id, required this.title, required this.message, required this.room, required this.deviceId, required this.timeAgo, + required this.createdAt, }); } class HomePatientController extends GetxController { - // Patient info - final patientName = 'Mrs. Shinta'.obs; - - // Banner carousel + final FirestoreService _firestoreService = FirestoreService(); + final RealtimeDatabaseService _realtimeService = RealtimeDatabaseService(); + + final deviceId = ''.obs; + final roomId = ''.obs; + final patientName = 'Patient'.obs; + final userEmail = ''.obs; + final currentCarouselIndex = 0.obs; final bannerImages = [ 'assets/images/banner-slider3.png', 'assets/images/banner-slider4.png', ]; - - // Current monitoring data + final currentInfusData = InfusData( - dropsPerMinute: '30 tetes per menit', - room: 'MAWAR 002', - deviceId: 'SI001', - updateTime: '5min ago', + dropsPerMinute: '0', + room: '-', + deviceId: '-', + updateTime: '-', + deviceStatus: 'disconnected', ).obs; - - // Chart data dengan 10 data points - final chartDataPoints = [ - ChartDataPoint(hour: 8, dropsPerMinute: 25), - ChartDataPoint(hour: 9, dropsPerMinute: 28), - ChartDataPoint(hour: 10, dropsPerMinute: 30), - ChartDataPoint(hour: 11, dropsPerMinute: 32), - ChartDataPoint(hour: 12, dropsPerMinute: 35), - ChartDataPoint(hour: 13, dropsPerMinute: 33), - ChartDataPoint(hour: 14, dropsPerMinute: 30), - ChartDataPoint(hour: 15, dropsPerMinute: 28), - ChartDataPoint(hour: 16, dropsPerMinute: 26), - ChartDataPoint(hour: 17, dropsPerMinute: 30), - ].obs; - - // Notifications list - final notificationsList = [ - NotificationData( - title: 'Peringatan!!', - message: 'Aliran infus telah terhenti. Mohon hubungi perawat segera.', - room: 'MAWAR 002', - deviceId: 'SI001', - timeAgo: '5min ago', - ), - NotificationData( - title: 'Peringatan!!', - message: 'Aliran infus telah terhenti. Mohon hubungi perawat segera.', - room: 'MELATI 001', - deviceId: 'SI002', - timeAgo: '10min ago', - ), - ].obs; + + final chartDataPoints = [].obs; + final notificationsList = [].obs; + + final isLoading = true.obs; + final isLoadingHistory = true.obs; + final isLoadingNotifications = true.obs; + + StreamSubscription? _realtimeSubscription; + StreamSubscription? _notificationsSubscription; + StreamSubscription? _historySubscription; @override void onInit() { super.onInit(); + _loadDataFromArguments(); } @override @@ -96,14 +95,255 @@ class HomePatientController extends GetxController { @override void onClose() { + _realtimeSubscription?.cancel(); + _notificationsSubscription?.cancel(); + _historySubscription?.cancel(); super.onClose(); } + void _loadDataFromArguments() async { + try { + final args = Get.arguments as Map?; + + if (args != null) { + deviceId.value = args['deviceId'] ?? ''; + patientName.value = args['userName'] ?? 'Patient'; + userEmail.value = args['userEmail'] ?? ''; + } + + if (deviceId.value.isEmpty) { + AppSnackbar.error('Device ID tidak ditemukan'); + isLoading.value = false; + return; + } + + await _loadPatientData(); + + if (roomId.value.isNotEmpty) { + _startRealtimeMonitoring(); + _loadHistoryData(); + _loadNotifications(); + } else { + AppSnackbar.error('Room ID tidak ditemukan'); + isLoading.value = false; + } + } catch (e) { + AppSnackbar.error('Gagal memuat data: $e'); + isLoading.value = false; + } + } + + Future _loadPatientData() async { + try { + isLoading.value = true; + + final patient = await _firestoreService.getPatientByDeviceId( + deviceId.value, + ); + + if (patient != null) { + patientName.value = patient.namePatient; + roomId.value = patient.roomId; + + final room = await _firestoreService.getRoomById(patient.roomId); + + if (currentInfusData.value.deviceStatus == 'disconnected') { + currentInfusData.value = InfusData( + dropsPerMinute: '0', + room: room?.roomName ?? patient.roomId, + deviceId: patient.deviceId, + updateTime: 'Loading...', + deviceStatus: 'disconnected', + ); + } + } else { + AppSnackbar.error('Data pasien tidak ditemukan'); + } + } catch (e) { + AppSnackbar.error('Gagal memuat data pasien: $e'); + print('Error loading patient data: $e'); + } finally { + isLoading.value = false; + } + } + + void _startRealtimeMonitoring() { + try { + if (roomId.value.isEmpty || deviceId.value.isEmpty) { + print('Room ID or Device ID is empty'); + return; + } + + final dbRoomId = roomId.value.toLowerCase().replaceAll(' ', '_'); + + _realtimeSubscription?.cancel(); // Cancel previous subscription if exists + + _realtimeSubscription = _realtimeService + .getDeviceStream(dbRoomId, deviceId.value) + .listen( + (device) { + if (device != null) { + _updateCurrentInfusData(device); + } else { + print('Received null device data'); + } + }, + onError: (error) { + print('Realtime stream error: $error'); + AppSnackbar.error('Koneksi realtime terputus'); + }, + cancelOnError: false, + ); + } catch (e) { + print('Error starting monitoring: $e'); + AppSnackbar.error('Gagal memulai monitoring: $e'); + } + } + + void _updateCurrentInfusData(RealtimeMonitoringModel device) async { + try { + final room = await _firestoreService.getRoomById(device.roomId); + + currentInfusData.value = InfusData( + dropsPerMinute: '${device.dropRate.toStringAsFixed(0)}', + room: room?.roomName ?? device.roomId, + deviceId: device.deviceId, + updateTime: _getLastUpdateText(device.lastUpdate), + deviceStatus: device.deviceStatus, + ); + } catch (e) { + print('Error updating infus data: $e'); + AppSnackbar.error('Gagal update data: $e'); + } + } + + void _loadHistoryData() { + try { + isLoadingHistory.value = true; + + _historySubscription?.cancel(); // Cancel previous subscription if exists + + _historySubscription = _firestoreService + .getDeviceHistoriesStream(deviceId.value) + .listen( + (histories) { + try { + if (histories.isNotEmpty) { + _processHistoryData(histories); + } else { + chartDataPoints.value = []; + } + isLoadingHistory.value = false; + } catch (e) { + print('Error processing history: $e'); + chartDataPoints.value = []; + isLoadingHistory.value = false; + } + }, + onError: (error) { + print('History stream error: $error'); + chartDataPoints.value = []; + isLoadingHistory.value = false; + AppSnackbar.error('Gagal memuat history: $error'); + }, + cancelOnError: false, + ); + } catch (e) { + print('Error loading history: $e'); + isLoadingHistory.value = false; + AppSnackbar.error('Gagal memuat history: $e'); + } + } + + void _processHistoryData(List histories) { + try { + histories.sort((a, b) => b.timestamp.compareTo(a.timestamp)); + final latestHistories = histories.take(10).toList(); + latestHistories.sort((a, b) => a.timestamp.compareTo(b.timestamp)); + final List points = latestHistories.map((history) { + return ChartDataPoint( + hour: history.timestamp.hour, + dropsPerMinute: history.dropPerMinute, + ); + }).toList(); + + chartDataPoints.value = points; + } catch (e) { + print('Error processing history data: $e'); + chartDataPoints.value = []; + } + } + + void _loadNotifications() { + try { + isLoadingNotifications.value = true; + + _notificationsSubscription + ?.cancel(); // Cancel previous subscription if exists + + _notificationsSubscription = _firestoreService + .getNotificationsStream() + .listen( + (notifications) async { + try { + final filteredNotifications = notifications + .where((n) => n.deviceId == deviceId.value) + .toList(); + + final List notifList = []; + + for (var notif in filteredNotifications) { + try { + final patient = await _firestoreService + .getPatientByDeviceId(notif.deviceId); + final room = patient != null + ? await _firestoreService.getRoomById(patient.roomId) + : null; + + notifList.add( + NotificationData( + id: notif.id, + title: notif.title, + message: notif.message, + room: room?.roomName ?? '-', + deviceId: notif.deviceId, + timeAgo: _getLastUpdateText(notif.createdAt), + createdAt: notif.createdAt, + ), + ); + } 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; + isLoadingNotifications.value = false; + } catch (e) { + print('Error processing notifications: $e'); + isLoadingNotifications.value = false; + } + }, + onError: (error) { + print('Notifications stream error: $error'); + isLoadingNotifications.value = false; + AppSnackbar.error('Gagal memuat notifikasi: $error'); + }, + cancelOnError: false, + ); + } catch (e) { + print('Error loading notifications: $e'); + isLoadingNotifications.value = false; + AppSnackbar.error('Gagal memuat notifikasi: $e'); + } + } + void updateCarouselIndex(int index) { currentCarouselIndex.value = index; } - // Show call dialog void showCallDialog(BuildContext context) { showDialog( context: context, @@ -116,36 +356,161 @@ class HomePatientController extends GetxController { 'Hubungi Perawat', style: TextStyle(fontWeight: FontWeight.bold), ), - content: const Text('Apakah Anda ingin menghubungi perawat?'), + content: const Text( + 'Apakah Anda ingin menghubungi perawat melalui WhatsApp?', + ), actions: [ TextButton( onPressed: () => Navigator.of(context).pop(), child: Text('Batal', style: TextStyle(color: Colors.grey[600])), ), ElevatedButton( - onPressed: () { + onPressed: () async { Navigator.of(context).pop(); - // Implement call functionality - Get.snackbar( - 'Menghubungi', - 'Sedang menghubungi perawat...', - snackPosition: SnackPosition.BOTTOM, - backgroundColor: Colors.blue, - colorText: Colors.white, - ); + await _openWhatsApp(); }, style: ElevatedButton.styleFrom( - backgroundColor: const Color(0xFF2196F3), + backgroundColor: const Color( + 0xFF25D366, + ), // WhatsApp green color foregroundColor: Colors.white, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(8), ), ), - child: const Text('Hubungi'), + child: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.phone, size: 18), + SizedBox(width: 8), + Text('Hubungi'), + ], + ), ), ], ); }, ); } -} \ No newline at end of file + + Future _openWhatsApp() async { + try { + const String nursePhoneNumber = + '6281234567890'; + + final String message = Uri.encodeComponent( + 'Halo, saya ${patientName.value} di ruangan ${currentInfusData.value.room}. ' + 'Saya membutuhkan bantuan perawat untuk device ${deviceId.value}.', + ); + + final String whatsappUrl = + 'https://wa.me/$nursePhoneNumber?text=$message'; + + if (await canLaunchUrl(Uri.parse(whatsappUrl))) { + await launchUrl( + Uri.parse(whatsappUrl), + mode: LaunchMode.externalApplication, + ); + } else { + AppSnackbar.error('Tidak dapat membuka WhatsApp'); + } + } catch (e) { + print('Error opening WhatsApp: $e'); + AppSnackbar.error('Gagal menghubungi perawat: $e'); + } + } + + void showLogoutDialog(BuildContext context) { + showDialog( + context: context, + builder: (BuildContext context) { + return AlertDialog( + backgroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(15), + ), + title: const Text( + 'Konfirmasi Logout', + style: TextStyle(fontWeight: FontWeight.bold, fontSize: 18), + ), + content: const Text( + 'Yakin ingin keluar?', + style: TextStyle(fontSize: 16), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + style: TextButton.styleFrom( + foregroundColor: Colors.grey[600], + padding: const EdgeInsets.symmetric( + vertical: 12, + horizontal: 20, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + child: const Text('Batal'), + ), + ElevatedButton( + onPressed: () async { + Navigator.of(context).pop(); + try { + await FirebaseAuth.instance.signOut(); + Get.offAllNamed('/login'); + } catch (e) { + print('Error logging out: $e'); + AppSnackbar.error('Gagal logout: $e'); + } + }, + style: ElevatedButton.styleFrom( + backgroundColor: Colors.red, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric( + vertical: 12, + horizontal: 20, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + child: const Text( + 'Logout', + style: TextStyle(fontWeight: FontWeight.bold), + ), + ), + ], + ); + }, + ); + } + + String _getLastUpdateText(DateTime lastUpdate) { + try { + final difference = DateTime.now().difference(lastUpdate); + 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'; + } else { + return '${difference.inDays}d ago'; + } + } catch (e) { + print('Error calculating time difference: $e'); + return 'Unknown'; + } + } + + Future 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'); + } + } +} diff --git a/lib/app/modules/home-patient/views/home_patient_view.dart b/lib/app/modules/home-patient/views/home_patient_view.dart index 8cdb071..a9ebbb4 100644 --- a/lib/app/modules/home-patient/views/home_patient_view.dart +++ b/lib/app/modules/home-patient/views/home_patient_view.dart @@ -1,217 +1,304 @@ -// lib/app/modules/home_patient/views/home_patient_view.dart import 'package:flutter/material.dart'; import 'package:get/get.dart'; import '../controllers/home_patient_controller.dart'; import '../../../widgets/banner_slider.dart'; -import '../../../widgets/monitoring_patient_card.dart'; +import '../../../widgets/monitoring_card.dart'; import '../../../widgets/history_grafik_card.dart'; import '../../../widgets/notification_patient_card.dart'; +import '../../home/controllers/home_controller.dart'; class HomePatientView extends GetView { const HomePatientView({super.key}); - + @override Widget build(BuildContext context) { Get.put(HomePatientController()); - + return Scaffold( backgroundColor: Colors.white, body: SafeArea( - child: SingleChildScrollView( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Header - Padding( - padding: const EdgeInsets.all(20), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Obx( - () => Text( - 'Halo ${controller.patientName.value}', - style: const TextStyle( - fontSize: 24, + child: RefreshIndicator( + onRefresh: controller.refreshData, + child: SingleChildScrollView( + physics: const AlwaysScrollableScrollPhysics(), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header + Padding( + padding: const EdgeInsets.all(20), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Obx( + () => Text( + 'Halo ${controller.patientName.value}', + style: const TextStyle( + fontSize: 24, + fontWeight: FontWeight.bold, + ), + overflow: TextOverflow.ellipsis, + ), + ), + ), + const SizedBox(width: 12), + InkWell( + onTap: () => controller.showLogoutDialog(context), + child: Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + border: Border.all(color: Colors.grey[300]!), + borderRadius: BorderRadius.circular(10), + ), + child: const Icon(Icons.logout, color: Colors.red), + ), + ), + ], + ), + ), + + // Banner Slider + BannerSlider( + bannerImages: controller.bannerImages, + currentCarouselIndex: controller.currentCarouselIndex, + onPageChanged: controller.updateCarouselIndex, + ), + + const SizedBox(height: 15), + + // Monitoring Infus Section + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text( + 'Monitoring Infus', + style: TextStyle( + fontSize: 18, fontWeight: FontWeight.bold, ), ), - ), - Container( - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - border: Border.all(color: Colors.grey[300]!), - borderRadius: BorderRadius.circular(10), - ), - child: const Icon( - Icons.logout, - color: Colors.red, - ), - ), - ], - ), - ), - - // Banner Slider - Gunakan widget universal - BannerSlider( - bannerImages: controller.bannerImages, - currentCarouselIndex: controller.currentCarouselIndex, - onPageChanged: controller.updateCarouselIndex, - ), - - const SizedBox(height: 15), - - // Monitoring Infus Section - Padding( - padding: const EdgeInsets.symmetric(horizontal: 20), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - const Text( - 'Monitoring Infus', - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, - ), - ), - TextButton( - onPressed: () {}, - child: const Text( - 'See all', - style: TextStyle( - color: Color(0xFF2196F3), - fontWeight: FontWeight.w600, + TextButton( + onPressed: () { + // TODO: Navigate to monitoring detail + }, + child: const Text( + 'See all', + style: TextStyle( + color: Color(0xFF2196F3), + fontWeight: FontWeight.w600, + ), ), ), - ), - ], - ), - ), - - // Monitoring Card - Padding( - padding: const EdgeInsets.symmetric(horizontal: 20), - child: Obx( - () => MonitoringPatientCard( - dropsPerMinute: controller.currentInfusData.value.dropsPerMinute, - room: controller.currentInfusData.value.room, - deviceId: controller.currentInfusData.value.deviceId, - updateTime: controller.currentInfusData.value.updateTime, - onTap: () { - // Handle bookmark tap - }, + ], ), ), - ), - - const SizedBox(height: 15), - - // History Data Section - Padding( - padding: const EdgeInsets.symmetric(horizontal: 20), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - const Text( - 'History Data', - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, - ), - ), - TextButton( - onPressed: () {}, - child: const Text( - 'See all', - style: TextStyle( - color: Color(0xFF2196F3), - fontWeight: FontWeight.w600, - ), - ), - ), - ], - ), - ), - - // Chart Card - Padding( - padding: const EdgeInsets.symmetric(horizontal: 20), - child: Obx( - () => HistoryGrafikCard( - dataPoints: controller.chartDataPoints.toList(), - onBookmarkTap: () { - // Handle bookmark tap - }, - ), - ), - ), - - const SizedBox(height: 15), - - // History Notifikasi Section - Padding( - padding: const EdgeInsets.symmetric(horizontal: 20), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - const Text( - 'History Notifikasi', - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, - ), - ), - TextButton( - onPressed: () {}, - child: const Text( - 'See all', - style: TextStyle( - color: Color(0xFF2196F3), - fontWeight: FontWeight.w600, - ), - ), - ), - ], - ), - ), - - // Notifications List - Obx( - () => ListView.builder( - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), + + // Monitoring Card - Gunakan MonitoringCard dari widgets + Padding( padding: const EdgeInsets.symmetric(horizontal: 20), - itemCount: controller.notificationsList.length, - itemBuilder: (context, index) { - final notification = controller.notificationsList[index]; - return NotificationPatientCard( - title: notification.title, - message: notification.message, - room: notification.room, - deviceId: notification.deviceId, - timeAgo: notification.timeAgo, + child: Obx(() { + if (controller.isLoading.value) { + return Container( + padding: const EdgeInsets.all(32), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: Colors.grey[200]!), + ), + child: const Center(child: CircularProgressIndicator()), + ); + } + + 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 + dropsPerMinute: infusData.dropsPerMinute, + lastUpdate: + DateTime.now(), // Will be calculated from updateTime + deviceStatus: infusData.deviceStatus, + ); + + return MonitoringCard( + monitoring: monitoring, onTap: () { - // Handle notification tap + // TODO: Navigate to detail }, ); - }, + }), ), - ), - - const SizedBox(height: 20), - ], + + const SizedBox(height: 15), + + // History Data Section + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + 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 + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Obx(() { + if (controller.isLoadingHistory.value) { + return Container( + padding: const EdgeInsets.all(32), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.grey[200]!), + ), + child: const Center(child: CircularProgressIndicator()), + ); + } + + return HistoryGrafikCard( + dataPoints: controller.chartDataPoints.toList(), + onBookmarkTap: () { + // TODO: Bookmark functionality + }, + ); + }), + ), + + const SizedBox(height: 15), + + // History Notifikasi Section + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + 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, + ), + ), + ), + ], + ), + ), + + // Notifications List + Obx(() { + if (controller.isLoadingNotifications.value) { + return const Padding( + padding: EdgeInsets.all(32), + child: Center(child: CircularProgressIndicator()), + ); + } + + if (controller.notificationsList.isEmpty) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Container( + padding: const EdgeInsets.all(32), + decoration: BoxDecoration( + color: Colors.grey[50], + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.grey[200]!), + ), + child: Center( + child: Column( + children: [ + Icon( + Icons.notifications_none, + size: 48, + color: Colors.grey[400], + ), + const SizedBox(height: 12), + Text( + 'Belum ada notifikasi', + style: TextStyle( + fontSize: 14, + color: Colors.grey[600], + ), + ), + ], + ), + ), + ), + ); + } + + return ListView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + padding: const EdgeInsets.symmetric(horizontal: 20), + itemCount: controller.notificationsList.length, + itemBuilder: (context, index) { + final notification = controller.notificationsList[index]; + return NotificationPatientCard( + title: notification.title, + message: notification.message, + room: notification.room, + deviceId: notification.deviceId, + timeAgo: notification.timeAgo, + onTap: () { + // TODO: Handle notification tap + }, + ); + }, + ); + }), + + const SizedBox(height: 20), + ], + ), ), ), ), - + // Floating Action Button - Call Nurse floatingActionButton: FloatingActionButton( onPressed: () => controller.showCallDialog(context), backgroundColor: const Color(0xFF2196F3), - child: const Icon( - Icons.phone, - color: Colors.white, - ), + child: const Icon(Icons.phone, color: Colors.white), ), ); } -} \ No newline at end of file +} diff --git a/lib/app/modules/home/controllers/home_controller.dart b/lib/app/modules/home/controllers/home_controller.dart index be3771c..5eb410f 100644 --- a/lib/app/modules/home/controllers/home_controller.dart +++ b/lib/app/modules/home/controllers/home_controller.dart @@ -1,64 +1,146 @@ -// lib/app/modules/home/controllers/home_controller.dart import 'package:get/get.dart'; import 'package:flutter/material.dart'; +import 'package:firebase_auth/firebase_auth.dart'; +import 'dart:async'; +import '../../../services/firestore_service.dart'; +import '../../../services/realtime_database_service.dart'; +import '../../../models/patient_model.dart'; +import '../../../models/room_model.dart'; +import '../../../models/realtime_monitoring_model.dart'; +import '../../../widgets/app_snackbar.dart'; class InfusMonitoring { final String id; final String patientName; final String deviceId; final String room; + final String roomId; final String dropsPerMinute; final DateTime lastUpdate; - final DateTime createdAt; // Tambahkan properti createdAt + final String deviceStatus; InfusMonitoring({ required this.id, required this.patientName, required this.deviceId, required this.room, + required this.roomId, required this.dropsPerMinute, required this.lastUpdate, - required this.createdAt, // Pastikan ini diisi saat pembuatan objek + required this.deviceStatus, }); } class HomeController extends GetxController { final searchController = TextEditingController(); + final FirestoreService _firestoreService = FirestoreService(); + final RealtimeDatabaseService _realtimeService = RealtimeDatabaseService(); - // Banner carousel final currentCarouselIndex = 0.obs; final bannerImages = [ 'assets/images/banner-slider1.png', 'assets/images/banner-slider2.png', ]; - final monitoringList = [ - InfusMonitoring( - id: '1', - patientName: 'Mrs. Shinta', - deviceId: 'SI001', - room: 'MAWAR 002', - dropsPerMinute: '30 tetes per menit', - lastUpdate: DateTime.now().subtract(const Duration(minutes: 5)), - createdAt: DateTime.now(), // Menambahkan createdAt - ), - InfusMonitoring( - id: '2', - patientName: 'Mr. Suparjo', - deviceId: 'SI001', - room: 'MELATI 001', - dropsPerMinute: '30 tetes per menit', - lastUpdate: DateTime.now().subtract(const Duration(minutes: 5)), - createdAt: DateTime.now(), // Menambahkan createdAt - ), - ].obs; + final monitoringList = [].obs; + final isLoading = true.obs; + + StreamSubscription? _patientsSubscription; + StreamSubscription? _realtimeSubscription; + + @override + void onInit() { + super.onInit(); + _initializeMonitoring(); + } @override void onClose() { searchController.dispose(); + _patientsSubscription?.cancel(); + _realtimeSubscription?.cancel(); super.onClose(); } + void _initializeMonitoring() { + isLoading.value = true; + + _patientsSubscription = _firestoreService.getPatientsStream().listen( + (patients) async { + _realtimeSubscription = _realtimeService.getAllDevicesStream().listen(( + realtimeDevices, + ) { + _combineMonitoringData(patients, realtimeDevices); + }); + }, + onError: (error) { + isLoading.value = false; + AppSnackbar.error('Gagal memuat data'); + }, + ); + } + + void _combineMonitoringData( + List patients, + List realtimeDevices, + ) async { + final List combinedData = []; + + for (var patient in patients) { + final realtimeDevice = realtimeDevices.firstWhereOrNull( + (device) => device.deviceId == patient.deviceId, + ); + + final roomName = await _getRoomName(patient.roomId); + final dropRate = realtimeDevice?.dropRate ?? 0.0; + final lastUpdate = realtimeDevice?.lastUpdate ?? DateTime.now(); + final deviceStatus = realtimeDevice?.deviceStatus ?? 'disconnected'; + + combinedData.add( + InfusMonitoring( + id: patient.id, + patientName: patient.namePatient, + deviceId: patient.deviceId, + room: roomName, + roomId: patient.roomId, + dropsPerMinute: '${dropRate.toStringAsFixed(0)} tpm', + lastUpdate: lastUpdate, + deviceStatus: deviceStatus, + ), + ); + } + + monitoringList.value = combinedData; + isLoading.value = false; + } + + Future _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; + } + } + + Future controlInfusServo( + String deviceId, + String roomId, + bool open, + ) async { + try { + final dbRoomId = roomId.toLowerCase().replaceAll(' ', '_'); + await _realtimeService.controlServo(dbRoomId, deviceId, open); + AppSnackbar.success(open ? 'Infus dibuka' : 'Infus ditutup'); + } catch (e) { + AppSnackbar.error('Gagal mengontrol infus'); + } + } + void updateCarouselIndex(int index) { currentCarouselIndex.value = index; } @@ -68,33 +150,54 @@ class HomeController extends GetxController { context: context, builder: (BuildContext context) { return AlertDialog( + backgroundColor: Colors.white, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(15), ), title: const Text( 'Konfirmasi Logout', - style: TextStyle(fontWeight: FontWeight.bold), + style: TextStyle(fontWeight: FontWeight.bold, fontSize: 18), + ), + content: const Text( + 'Yakin ingin keluar?', + style: TextStyle(fontSize: 16), ), - content: const Text('Apakah Anda yakin ingin keluar?'), actions: [ TextButton( onPressed: () => Navigator.of(context).pop(), - child: Text('Batal', style: TextStyle(color: Colors.grey[600])), + style: TextButton.styleFrom( + foregroundColor: Colors.grey[600], + padding: const EdgeInsets.symmetric( + vertical: 12, + horizontal: 20, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + child: const Text('Batal'), ), ElevatedButton( - onPressed: () { + onPressed: () async { Navigator.of(context).pop(); - // Navigasi ke halaman login + await FirebaseAuth.instance.signOut(); Get.offAllNamed('/login'); }, style: ElevatedButton.styleFrom( backgroundColor: Colors.red, foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric( + vertical: 12, + horizontal: 20, + ), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(8), ), ), - child: const Text('Logout'), + child: const Text( + 'Logout', + style: TextStyle(fontWeight: FontWeight.bold), + ), ), ], ); @@ -105,7 +208,7 @@ class HomeController extends GetxController { String getLastUpdateText(DateTime lastUpdate) { final difference = DateTime.now().difference(lastUpdate); if (difference.inMinutes < 60) { - return '${difference.inMinutes}min ago'; + return '${difference.inMinutes}m ago'; } else if (difference.inHours < 24) { return '${difference.inHours}h ago'; } else { diff --git a/lib/app/modules/home/views/home_view.dart b/lib/app/modules/home/views/home_view.dart index 8fa3fbc..5a146a7 100644 --- a/lib/app/modules/home/views/home_view.dart +++ b/lib/app/modules/home/views/home_view.dart @@ -1,4 +1,3 @@ -// lib/app/modules/home/views/home_view.dart import 'package:flutter/material.dart'; import 'package:get/get.dart'; import '../controllers/home_controller.dart'; @@ -7,99 +6,129 @@ import '../../../widgets/monitoring_card.dart'; class HomeView extends GetView { const HomeView({super.key}); - + @override Widget build(BuildContext context) { Get.put(HomeController()); - + return Scaffold( backgroundColor: Colors.white, body: SafeArea( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - // Header - Padding( - padding: const EdgeInsets.all(20), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - const Text( - 'Halo Perawat', - style: TextStyle( - fontSize: 24, - fontWeight: FontWeight.bold, - ), - ), - InkWell( - onTap: () => controller.showLogoutDialog(context), - child: Container( - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - border: Border.all(color: Colors.grey[300]!), - borderRadius: BorderRadius.circular(10), - ), - child: const Icon(Icons.logout, color: Colors.red), - ), - ), - ], - ), - ), - - // Banner Slider - Gunakan widget universal + _buildHeader(context), BannerSlider( bannerImages: controller.bannerImages, currentCarouselIndex: controller.currentCarouselIndex, onPageChanged: controller.updateCarouselIndex, ), - const SizedBox(height: 15), - - // Monitoring Infus Section - Padding( - padding: const EdgeInsets.symmetric(horizontal: 20), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - const Text( - 'Monitoring Infus', - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, - ), - ), - TextButton( - onPressed: () {}, - child: const Text( - 'See all', - style: TextStyle(color: Color(0xFF0091EA)), - ), - ), - ], - ), - ), - - // Monitoring List - Expanded( - child: Obx( - () => ListView.builder( - padding: const EdgeInsets.symmetric(horizontal: 20), - itemCount: controller.monitoringList.length, - itemBuilder: (context, index) { - final item = controller.monitoringList[index]; - return MonitoringCard( - item: item, - onTap: () { - // Handle tap jika diperlukan - }, - ); - }, - ), - ), - ), + _buildSectionHeader(), + Expanded(child: _buildMonitoringList()), ], ), ), ); } + + Widget _buildHeader(BuildContext context) { + return Padding( + padding: const EdgeInsets.all(20), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text( + 'Halo Perawat', + style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold), + ), + InkWell( + onTap: () => controller.showLogoutDialog(context), + child: Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + border: Border.all(color: Colors.grey[300]!), + borderRadius: BorderRadius.circular(10), + ), + child: const Icon(Icons.logout, color: Colors.red), + ), + ), + ], + ), + ); + } + + Widget _buildSectionHeader() { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + 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)), + ), + ), + ], + ), + ); + } + + Widget _buildMonitoringList() { + return Obx(() { + if (controller.isLoading.value) { + return const Center( + child: CircularProgressIndicator(color: Color(0xFF0091EA)), + ); + } + + 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, + }, + ), + ); + }, + ); + }); + } + + 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]), + ), + ], + ), + ); + } } \ No newline at end of file diff --git a/lib/app/modules/login/controllers/login_controller.dart b/lib/app/modules/login/controllers/login_controller.dart index 481478d..19aa2ac 100644 --- a/lib/app/modules/login/controllers/login_controller.dart +++ b/lib/app/modules/login/controllers/login_controller.dart @@ -1,39 +1,172 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; -import 'package:url_launcher/url_launcher.dart'; +import 'package:url_launcher/url_launcher.dart'; +import 'package:firebase_auth/firebase_auth.dart'; +import 'package:cloud_firestore/cloud_firestore.dart'; class LoginController extends GetxController { final usernameController = TextEditingController(); final passwordController = TextEditingController(); final isPasswordHidden = true.obs; + final isLoading = false.obs; + + final FirebaseAuth _auth = FirebaseAuth.instance; + final FirebaseFirestore _firestore = FirebaseFirestore.instance; void togglePasswordVisibility() { isPasswordHidden.value = !isPasswordHidden.value; } - void login() { + void login() async { + // Validasi input if (usernameController.text.isEmpty || passwordController.text.isEmpty) { Get.snackbar( 'Error', - 'Username dan password harus diisi', + 'Email dan password harus diisi', backgroundColor: Colors.red[100], + colorText: Colors.red[900], + snackPosition: SnackPosition.TOP, ); return; } - Get.offAllNamed('/navbar'); // Adjust to your navigation path + // Validasi format email + if (!GetUtils.isEmail(usernameController.text)) { + Get.snackbar( + 'Error', + 'Format email tidak valid', + backgroundColor: Colors.red[100], + colorText: Colors.red[900], + snackPosition: SnackPosition.TOP, + ); + return; + } + + 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!', + backgroundColor: Colors.green[100], + colorText: Colors.green[900], + snackPosition: SnackPosition.TOP, + ); + + // Navigasi ke home_patient dengan device_id + Get.offAllNamed( + '/home-patient', + arguments: { + 'deviceId': deviceId, + 'userName': userName, + 'userEmail': usernameController.text.trim(), + }, + ); + } else { + // User ada tapi tidak punya device_id + Get.snackbar( + 'Error', + 'Akun Anda belum terdaftar dengan device', + backgroundColor: Colors.orange[100], + colorText: Colors.orange[900], + snackPosition: SnackPosition.TOP, + ); + await _auth.signOut(); + } + } else { + // User TIDAK ada di collection users = Perawat/Admin + Get.snackbar( + 'Berhasil', + 'Login berhasil! Selamat datang, Perawat', + backgroundColor: Colors.green[100], + colorText: Colors.green[900], + snackPosition: SnackPosition.TOP, + ); + + // Navigasi ke navbar/home perawat + Get.offAllNamed('/navbar'); + } + } + } on FirebaseAuthException catch (e) { + String errorMessage = ''; + + switch (e.code) { + case 'user-not-found': + errorMessage = 'Email tidak terdaftar'; + break; + case 'wrong-password': + errorMessage = 'Password salah'; + break; + case 'invalid-email': + errorMessage = 'Format email tidak valid'; + break; + case 'user-disabled': + errorMessage = 'Akun telah dinonaktifkan'; + break; + case 'too-many-requests': + errorMessage = 'Terlalu banyak percobaan login. Coba lagi nanti'; + break; + case 'network-request-failed': + errorMessage = 'Tidak ada koneksi internet'; + break; + default: + errorMessage = 'Terjadi kesalahan: ${e.message}'; + } + + Get.snackbar( + 'Login Gagal', + errorMessage, + backgroundColor: Colors.red[100], + colorText: Colors.red[900], + snackPosition: SnackPosition.TOP, + duration: const Duration(seconds: 3), + ); + } catch (e) { + Get.snackbar( + 'Error', + 'Terjadi kesalahan: $e', + backgroundColor: Colors.red[100], + colorText: Colors.red[900], + snackPosition: SnackPosition.TOP, + ); + } finally { + isLoading.value = false; + } } void contactNurse() async { - const phoneNumber = '+62 812-3190-1277'; - final url = 'https://wa.me/$phoneNumber'; // WhatsApp link for contacting - if (await canLaunch(url)) { - await launch(url); + const phoneNumber = '+6281231901277'; // Tanpa spasi + final url = Uri.parse('https://wa.me/$phoneNumber'); + + if (await canLaunchUrl(url)) { + await launchUrl(url, mode: LaunchMode.externalApplication); } else { Get.snackbar( 'Error', 'Tidak dapat membuka WhatsApp', backgroundColor: Colors.red[100], + colorText: Colors.red[900], ); } } diff --git a/lib/app/modules/login/views/login_view.dart b/lib/app/modules/login/views/login_view.dart index 08e5d53..d8e6500 100644 --- a/lib/app/modules/login/views/login_view.dart +++ b/lib/app/modules/login/views/login_view.dart @@ -14,118 +14,126 @@ class LoginView extends GetView { body: SafeArea( child: SingleChildScrollView( padding: const EdgeInsets.all(24), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const SizedBox(height: 20), - const Text( - 'Login', - style: TextStyle(fontSize: 30, fontWeight: FontWeight.bold), - ), - const SizedBox(height: 50), - Center( - child: Image.asset( - 'assets/images/banner-logo.png', - width: 300, - height: 130, - fit: BoxFit.contain, + child: Center( + // Wrap the entire content inside Center widget to center vertically + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 20), + const Text( + 'Login', + style: TextStyle(fontSize: 30, fontWeight: FontWeight.bold), ), - ), - const SizedBox(height: 50), - Container( - decoration: BoxDecoration( - color: Colors.grey[100], - borderRadius: BorderRadius.circular(15), - ), - child: TextField( - controller: controller.usernameController, - decoration: InputDecoration( - hintText: 'Masukkan username', - hintStyle: TextStyle(color: Colors.grey[400]), - prefixIcon: Icon( - Icons.mail_outline, - color: Colors.grey[400], - ), - border: InputBorder.none, - contentPadding: const EdgeInsets.all(20), - ), - ), - ), - const SizedBox(height: 20), - Obx( - () => Container( + const SizedBox(height: 50), + Container( decoration: BoxDecoration( color: Colors.grey[100], borderRadius: BorderRadius.circular(15), ), child: TextField( - controller: controller.passwordController, - obscureText: controller.isPasswordHidden.value, + controller: controller.usernameController, + keyboardType: TextInputType.emailAddress, decoration: InputDecoration( - hintText: 'Masukkan password', + hintText: 'Masukkan email', hintStyle: TextStyle(color: Colors.grey[400]), prefixIcon: Icon( - Icons.lock_outline, + Icons.mail_outline, color: Colors.grey[400], ), - suffixIcon: IconButton( - icon: Icon( - controller.isPasswordHidden.value - ? Icons.visibility_off_outlined - : Icons.visibility_outlined, - color: Colors.grey[400], - ), - onPressed: controller.togglePasswordVisibility, - ), border: InputBorder.none, contentPadding: const EdgeInsets.all(20), ), ), ), - ), - const SizedBox(height: 30), - SizedBox( - width: double.infinity, - height: 55, - child: ElevatedButton( - onPressed: controller.login, - style: ElevatedButton.styleFrom( - backgroundColor: const Color(0xFF1976D2), - shape: RoundedRectangleBorder( + const SizedBox(height: 20), + Obx( + () => Container( + decoration: BoxDecoration( + color: Colors.grey[100], borderRadius: BorderRadius.circular(15), ), - ), - child: const Text( - 'Login', - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.w600, - color: Colors.white, - ), - ), - ), - ), - const SizedBox(height: 20), - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Text( - "Tidak memiliki akun? ", - style: TextStyle(color: Colors.black87), - ), - TextButton( - onPressed: controller.contactNurse, - child: const Text( - 'Hubungi Perawat', - style: TextStyle( - color: Color(0xFF1976D2), - fontWeight: FontWeight.w600, + child: TextField( + controller: controller.passwordController, + obscureText: controller.isPasswordHidden.value, + decoration: InputDecoration( + hintText: 'Masukkan password', + hintStyle: TextStyle(color: Colors.grey[400]), + prefixIcon: Icon( + Icons.lock_outline, + color: Colors.grey[400], + ), + suffixIcon: IconButton( + icon: Icon( + controller.isPasswordHidden.value + ? Icons.visibility_off_outlined + : Icons.visibility_outlined, + color: Colors.grey[400], + ), + onPressed: controller.togglePasswordVisibility, + ), + border: InputBorder.none, + contentPadding: const EdgeInsets.all(20), ), ), ), - ], - ), - ], + ), + const SizedBox(height: 30), + Obx( + () => SizedBox( + width: double.infinity, + height: 55, + child: ElevatedButton( + onPressed: controller.isLoading.value + ? null + : controller.login, + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF1976D2), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(15), + ), + ), + child: controller.isLoading.value + ? const SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator( + color: Colors.white, + strokeWidth: 2, + ), + ) + : const Text( + 'Login', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + color: Colors.white, + ), + ), + ), + ), + ), + const SizedBox(height: 20), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Text( + "Tidak memiliki akun? ", + style: TextStyle(color: Colors.black87), + ), + TextButton( + onPressed: controller.contactNurse, + child: const Text( + 'Hubungi Perawat', + style: TextStyle( + color: Color(0xFF1976D2), + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + ], + ), ), ), ), diff --git a/lib/app/modules/notification/controllers/notification_controller.dart b/lib/app/modules/notification/controllers/notification_controller.dart index d8bddba..0663046 100644 --- a/lib/app/modules/notification/controllers/notification_controller.dart +++ b/lib/app/modules/notification/controllers/notification_controller.dart @@ -1,5 +1,11 @@ -// lib\app\modules\notification\controllers\notification_controller.dart +// lib/app/modules/notification/controllers/notification_controller.dart import 'package:get/get.dart'; +import 'package:flutter/material.dart'; +import 'dart:async'; +import '../../../services/firestore_service.dart'; +import '../../../models/notification_model.dart'; +import '../../../models/patient_model.dart'; +import '../../../models/room_model.dart'; class NotificationItem { final String id; @@ -46,50 +52,72 @@ class NotificationItem { } class NotificationController extends GetxController { - final selectedTab = 0.obs; + final FirestoreService _firestoreService = FirestoreService(); - final _allNotifications = [ - NotificationItem( - id: '1', - title: 'Peringatan!!', - message: 'Aliran infus telah terhenti. Segera periksa pasien dan ganti selang infus.', - room: 'MAWAR 002', - deviceId: 'SI001', - patientName: 'Mrs Shinta', - time: DateTime.now().subtract(const Duration(minutes: 5)), - isRead: false, - ), - NotificationItem( - id: '2', - title: 'Peringatan!!', - message: 'Aliran infus telah terhenti. Segera periksa pasien dan ganti selang infus.', - room: 'MAWAR 003', - deviceId: 'SI002', - patientName: 'Mr Budi', - time: DateTime.now().subtract(const Duration(hours: 2)), - isRead: false, - ), - NotificationItem( - id: '3', - title: 'Peringatan!!', - message: 'Aliran infus telah terhenti. Segera periksa pasien dan ganti selang infus.', - room: 'MAWAR 004', - deviceId: 'SI003', - patientName: 'Mrs Ani', - time: DateTime.now().subtract(const Duration(hours: 5)), - isRead: true, - ), - NotificationItem( - id: '4', - title: 'Peringatan!!', - message: 'Aliran infus telah terhenti. Segera periksa pasien dan ganti selang infus.', - room: 'MAWAR 005', - deviceId: 'SI004', - patientName: 'Mr Doni', - time: DateTime.now().subtract(const Duration(days: 1)), - isRead: true, - ), - ].obs; + final selectedTab = 0.obs; + final _allNotifications = [].obs; + final isLoading = true.obs; + + StreamSubscription? _notificationsSubscription; + + @override + void onInit() { + super.onInit(); + _initializeNotifications(); + } + + @override + void onClose() { + _notificationsSubscription?.cancel(); + super.onClose(); + } + + /// Initialize notifications dari Firestore + void _initializeNotifications() { + isLoading.value = true; + + _notificationsSubscription = _firestoreService + .getNotificationsStream() + .listen((notificationModels) async { + final List 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(), + ); + 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; + }); + } // Computed property untuk notifikasi yang difilter List get notifications { @@ -106,15 +134,35 @@ class NotificationController extends GetxController { selectedTab.value = index; } - void deleteNotification(String id) { - _allNotifications.removeWhere((notif) => notif.id == id); + Future deleteNotification(String id) async { + try { + await _firestoreService.deleteNotification(id); + // Local update akan dilakukan otomatis oleh stream + } catch (e) { + Get.snackbar( + 'Error', + 'Gagal menghapus notifikasi: $e', + snackPosition: SnackPosition.BOTTOM, + backgroundColor: Colors.redAccent, + colorText: Colors.white, + margin: const EdgeInsets.all(16), + ); + } } - void markAsRead(String id) { - final index = _allNotifications.indexWhere((notif) => notif.id == id); - if (index != -1) { - _allNotifications[index] = _allNotifications[index].copyWith(isRead: true); - _allNotifications.refresh(); + Future markAsRead(String id) async { + try { + await _firestoreService.markNotificationAsRead(id); + // Local update akan dilakukan otomatis oleh stream + } catch (e) { + Get.snackbar( + 'Error', + 'Gagal menandai notifikasi: $e', + snackPosition: SnackPosition.BOTTOM, + backgroundColor: Colors.redAccent, + colorText: Colors.white, + margin: const EdgeInsets.all(16), + ); } } @@ -133,4 +181,28 @@ class NotificationController extends GetxController { int get unreadCount { return _allNotifications.where((notif) => !notif.isRead).length; } + + /// Method untuk menambahkan notifikasi baru (bisa dipanggil dari monitoring) + Future addNotification({ + required String patientId, + required String deviceId, + required String title, + required String message, + }) async { + try { + final notification = NotificationModel( + id: '', // Will be auto-generated by Firestore + patientId: patientId, + deviceId: deviceId, + title: title, + message: message, + isRead: false, + createdAt: DateTime.now(), + ); + + await _firestoreService.addNotification(notification); + } catch (e) { + print('Error adding notification: $e'); + } + } } \ No newline at end of file diff --git a/lib/app/modules/profile/controllers/profile_controller.dart b/lib/app/modules/profile/controllers/profile_controller.dart index 7438e2d..5df3f21 100644 --- a/lib/app/modules/profile/controllers/profile_controller.dart +++ b/lib/app/modules/profile/controllers/profile_controller.dart @@ -1,12 +1,21 @@ -// lib/app/modules/profile/controllers/profile_controller.dart import 'package:get/get.dart'; +import 'package:cloud_firestore/cloud_firestore.dart'; +import 'package:firebase_database/firebase_database.dart'; +import '../../../services/firestore_service.dart'; +import '../../../services/realtime_database_service.dart'; +import '../../../models/patient_model.dart'; +import '../../../models/device_model.dart'; +import '../../../models/room_model.dart'; +import '../../../widgets/app_snackbar.dart'; class Patient { final String id; final String name; final String room; final String deviceId; - final String guardian; // Wali pasien + final String guardian; + final String email; + final String? userId; final String status; Patient({ @@ -15,40 +24,372 @@ class Patient { required this.room, required this.deviceId, required this.guardian, + required this.email, + this.userId, this.status = 'Aktif', }); + + Map toFirestore() { + return { + 'name_patient': name, + 'name_guardian': guardian, + 'device_id': deviceId, + 'room_id': room, + 'user_id': userId, + }; + } + + factory Patient.fromFirestore(DocumentSnapshot doc) { + final data = doc.data() as Map; + return Patient( + id: doc.id, + name: data['name_patient'] ?? '', + guardian: data['name_guardian'] ?? '', + deviceId: data['device_id'] ?? '', + room: data['room_id'] ?? '', + email: '', + userId: data['user_id'], + ); + } } class ProfileController extends GetxController { - final patients = [ - Patient( - id: '1', - name: 'Mrs. Shinta', - room: 'Ruang Mawar 002', - deviceId: 'SI001', - guardian: 'Budi Santoso', - ), - Patient( - id: '2', - name: 'Mr. Parjo', - room: 'Ruang Melati 001', - deviceId: 'SI002', - guardian: 'Siti Nurhaliza', - ), - ].obs; + final FirestoreService _firestoreService = FirestoreService(); + final RealtimeDatabaseService _realtimeService = RealtimeDatabaseService(); - void addPatient(Patient patient) { - patients.add(patient); + final patients = [].obs; + final isLoading = false.obs; + + @override + void onInit() { + super.onInit(); + _listenToPatients(); } - void updatePatient(String id, Patient updatedPatient) { - final index = patients.indexWhere((p) => p.id == id); - if (index != -1) { - patients[index] = updatedPatient; + void refreshPatients() { + _listenToPatients(); + } + + void _listenToPatients() { + _firestoreService.getPatientsStream().listen((patientModels) async { + final patientsWithEmail = []; + + for (var model in patientModels) { + try { + final email = await _getGuardianEmail(model.nameGuardian); + final roomName = await _getRoomName(model.roomId); + + patientsWithEmail.add( + Patient( + id: model.id, + name: model.namePatient, + guardian: model.nameGuardian, + deviceId: model.deviceId, + room: roomName, + email: email, + userId: null, + ), + ); + } catch (e) { + continue; + } + } + + patients.value = patientsWithEmail; + }, onError: (error) => AppSnackbar.error('Gagal memuat data')); + } + + Future _getGuardianEmail(String guardianName) async { + try { + final userDoc = await FirebaseFirestore.instance + .collection('users') + .where('role', isEqualTo: 'guardian') + .where('name', isEqualTo: guardianName) + .limit(1) + .get(); + + return userDoc.docs.isNotEmpty + ? userDoc.docs.first.data()['email'] ?? '' + : ''; + } catch (e) { + return ''; } } - void deletePatient(String id) { - patients.removeWhere((patient) => patient.id == id); + Future _getRoomName(String roomId) async { + try { + final roomDoc = await FirebaseFirestore.instance + .collection('rooms') + .doc(roomId) + .get(); + return roomDoc.data()?['room_name'] ?? roomId; + } catch (e) { + return roomId; + } } -} \ No newline at end of file + + Future addPatient({ + required String patientName, + required String guardianName, + required String roomName, + required String deviceId, + required String email, + required String password, + }) async { + try { + isLoading.value = true; + + if (!await _validateNewPatient(email, deviceId)) return; + + final roomId = _generateRoomId(roomName); + + // PERBAIKAN: Tambahkan device_id saat create user + final userId = await _firestoreService.createUser( + email: email, + password: password, + name: guardianName, + role: 'guardian', + deviceId: deviceId, // ✅ TAMBAHKAN INI + ); + + if (userId == null) throw Exception('Gagal membuat user'); + + await _ensureRoomExists(roomId, roomName); + await _createDevice(deviceId, roomId); + await _firestoreService.createPatient( + PatientModel( + id: '', + namePatient: patientName, + nameGuardian: guardianName, + deviceId: deviceId, + roomId: roomId, + ), + ); + + await _realtimeService.updateDeviceStatus(roomId, deviceId, 'connected'); + await _createDeviceInRealtimeDB(roomId, deviceId); + + Get.back(); + AppSnackbar.success('Pasien berhasil ditambahkan'); + } catch (e) { + AppSnackbar.error('Gagal menambahkan pasien: $e'); + } finally { + isLoading.value = false; + } + } + + Future _validateNewPatient(String email, String deviceId) async { + if (await _firestoreService.userExists(email)) { + AppSnackbar.warning('Email sudah terdaftar'); + return false; + } + + if (await _firestoreService.deviceExists(deviceId)) { + AppSnackbar.warning('Device ID sudah terdaftar'); + return false; + } + + if (await _firestoreService.patientExistsByDevice(deviceId)) { + AppSnackbar.warning('Device sudah digunakan'); + return false; + } + + return true; + } + + Future updatePatient({ + required String patientId, + required String patientName, + required String guardianName, + required String roomName, + required String deviceId, + required String email, + String? newPassword, + }) async { + try { + isLoading.value = true; + + final oldPatient = await _firestoreService.getPatientById(patientId); + if (oldPatient == null) throw Exception('Pasien tidak ditemukan'); + + final roomId = _generateRoomId(roomName); + + if (deviceId != oldPatient.deviceId) { + if (await _firestoreService.deviceExists(deviceId)) { + AppSnackbar.warning('Device ID sudah terdaftar'); + return; + } + } + + await _firestoreService.updatePatient( + patientId, + PatientModel( + id: patientId, + namePatient: patientName, + nameGuardian: guardianName, + deviceId: deviceId, + roomId: roomId, + ), + ); + + if (deviceId != oldPatient.deviceId || roomId != oldPatient.roomId) { + await _updateDeviceAndRoom(oldPatient, deviceId, roomId); + } + + await _ensureRoomExists(roomId, roomName); + + // PERBAIKAN: Update device_id di users collection jika device berubah + if (deviceId != oldPatient.deviceId) { + final userDoc = await _firestoreService.getUserByEmail(email); + if (userDoc != null) { + await _firestoreService.updateUser( + uid: userDoc.id, + name: guardianName, + email: email, + deviceId: deviceId, // ✅ UPDATE device_id + ); + } + } + + Get.back(); + AppSnackbar.success('Data berhasil diperbarui'); + } catch (e) { + AppSnackbar.error('Gagal memperbarui data: $e'); + } finally { + isLoading.value = false; + } + } + + Future _updateDeviceAndRoom( + PatientModel oldPatient, + String newDeviceId, + String newRoomId, + ) async { + await _firestoreService.deleteDevice(oldPatient.deviceId); + await _deleteDeviceFromRealtimeDB(oldPatient.roomId, oldPatient.deviceId); + await _createDevice(newDeviceId, newRoomId); + await _createDeviceInRealtimeDB(newRoomId, newDeviceId); + } + + // PERBAIKAN LENGKAP: Hapus dari semua collection + Authentication + Future deletePatient(String patientId, {String? password}) async { + try { + isLoading.value = true; + + final patient = await _firestoreService.getPatientById(patientId); + if (patient == null) throw Exception('Pasien tidak ditemukan'); + + // 1. Hapus dari Realtime Database + await _deleteDeviceFromRealtimeDB(patient.roomId, patient.deviceId); + + // 2. Hapus histories berdasarkan device_id + await _firestoreService.deleteHistoriesByDevice(patient.deviceId); + + // 3. Hapus notifications berdasarkan device_id + await _firestoreService.deleteNotificationsByDevice(patient.deviceId); + + // 4. Hapus schedules berdasarkan device_id + await _firestoreService.deleteSchedulesByDevice(patient.deviceId); + + // 5. Hapus device dari Firestore + await _firestoreService.deleteDevice(patient.deviceId); + + // 6. Cari user berdasarkan guardian name dan device_id + final userDoc = await FirebaseFirestore.instance + .collection('users') + .where('role', isEqualTo: 'guardian') + .where('name', isEqualTo: patient.nameGuardian) + .where('device_id', isEqualTo: patient.deviceId) + .limit(1) + .get(); + + if (userDoc.docs.isNotEmpty) { + final userId = userDoc.docs.first.id; + final userEmail = userDoc.docs.first.data()['email'] as String; + + // 7. Hapus dari collection users + await _firestoreService.deleteUser(userId); + + // 8. Hapus dari Firebase Authentication + // CATATAN: Memerlukan password untuk delete dari Authentication + if (password != null && password.isNotEmpty) { + await _firestoreService.deleteUserFromAuth(userEmail, password); + } else { + // Jika tidak ada password, tampilkan warning + print( + '⚠️ User tidak terhapus dari Authentication karena password tidak tersedia', + ); + AppSnackbar.warning( + 'User terhapus dari database, tapi tidak dari Authentication. Hubungi admin untuk menghapus akun sepenuhnya.', + ); + } + } + + // 9. Hapus patient dari Firestore + await _firestoreService.deletePatient(patientId); + + AppSnackbar.success('Pasien berhasil dihapus'); + } catch (e) { + AppSnackbar.error('Gagal menghapus pasien: $e'); + print('❌ Delete patient error: $e'); + } finally { + isLoading.value = false; + } + } + + Future _ensureRoomExists(String roomId, String roomName) async { + if (!await _firestoreService.roomExists(roomId)) { + await _firestoreService.createRoom( + RoomModel( + id: roomId, + roomName: roomName, + capacity: 10, + createdAt: DateTime.now(), + ), + ); + } + } + + Future _createDevice(String deviceId, String roomId) async { + await _firestoreService.createDevice( + DeviceModel( + idDevices: deviceId, + roomId: roomId, + devicesName: 'Infusion Device $deviceId', + ), + ); + } + + Future _createDeviceInRealtimeDB(String roomId, String deviceId) async { + final devicePath = 'realtime/rooms/$roomId/devices/$deviceId'; + await FirebaseDatabase.instance.ref().child(devicePath).set({ + 'controlling': { + 'servo_open': false, + 'last_command': DateTime.now().toIso8601String(), + }, + 'monitoring': { + 'device_status': 'connected', + 'drop_rate': 0, + 'last_update': DateTime.now().toIso8601String(), + }, + 'settings': {'servo_angle_open': 90, 'servo_angle_close': 0}, + }); + } + + Future _deleteDeviceFromRealtimeDB( + String roomId, + String deviceId, + ) async { + final devicePath = 'realtime/rooms/$roomId/devices/$deviceId'; + await FirebaseDatabase.instance.ref().child(devicePath).remove(); + } + + String _generateRoomId(String roomName) { + return roomName + .trim() + .toLowerCase() + .replaceAll(RegExp(r'[^a-z0-9]+'), '_') + .replaceAll(RegExp(r'_+'), '_') + .replaceAll(RegExp(r'^_|_$'), ''); + } +} diff --git a/lib/app/modules/profile/views/profile_view.dart b/lib/app/modules/profile/views/profile_view.dart index 4ddb129..def83ce 100644 --- a/lib/app/modules/profile/views/profile_view.dart +++ b/lib/app/modules/profile/views/profile_view.dart @@ -1,4 +1,3 @@ -// lib/app/modules/profile/views/profile_view.dart import 'package:flutter/material.dart'; import 'package:get/get.dart'; import '../controllers/profile_controller.dart'; @@ -18,7 +17,6 @@ class ProfileView extends GetView { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - // Header Section Container( color: Colors.white, padding: const EdgeInsets.all(20), @@ -36,10 +34,7 @@ class ProfileView extends GetView { Container( decoration: BoxDecoration( gradient: const LinearGradient( - colors: [ - Color(0xFF0091EA), - Color(0xFF0277BD), - ], + colors: [Color(0xFF0091EA), Color(0xFF0277BD)], ), borderRadius: BorderRadius.circular(12), boxShadow: [ @@ -50,73 +45,121 @@ class ProfileView extends GetView { ), ], ), - child: ElevatedButton( - onPressed: () { - Get.dialog( - const ModalPatientAddEdit(isEdit: false), - barrierDismissible: true, - ); - }, - style: ElevatedButton.styleFrom( - backgroundColor: Colors.transparent, - shadowColor: Colors.transparent, - padding: const EdgeInsets.symmetric( - horizontal: 20, - vertical: 12, - ), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - ), - child: const Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(Icons.add, color: Colors.white, size: 20), - SizedBox(width: 8), - Text( - 'Tambah', - style: TextStyle( - color: Colors.white, - fontWeight: FontWeight.w600, - fontSize: 15, - ), + child: Obx( + () => ElevatedButton( + onPressed: controller.isLoading.value + ? null + : () { + Get.dialog( + const ModalPatientAddEdit(isEdit: false), + barrierDismissible: true, + ); + }, + style: ElevatedButton.styleFrom( + backgroundColor: Colors.transparent, + shadowColor: Colors.transparent, + padding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 12, ), - ], + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + child: controller.isLoading.value + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + color: Colors.white, + strokeWidth: 2, + ), + ) + : const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.add, + color: Colors.white, + size: 20, + ), + SizedBox(width: 8), + Text( + 'Tambah', + style: TextStyle( + color: Colors.white, + fontWeight: FontWeight.w600, + fontSize: 15, + ), + ), + ], + ), ), ), ), ], ), ), - - // Patient List Expanded( - child: Obx( - () { - if (controller.patients.isEmpty) { - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - Icons.people_outline, - size: 80, - color: Colors.grey[400], - ), - const SizedBox(height: 16), - Text( - 'Belum ada data pasien', - style: TextStyle( - fontSize: 16, - color: Colors.grey[600], - ), - ), - ], - ), - ); - } + child: Obx(() { + if (controller.isLoading.value && controller.patients.isEmpty) { + return const Center( + child: CircularProgressIndicator(color: Color(0xFF0091EA)), + ); + } - return ListView.builder( + if (controller.patients.isEmpty) { + return RefreshIndicator( + onRefresh: () async { + controller.refreshPatients(); + await Future.delayed(const Duration(milliseconds: 500)); + }, + color: const Color(0xFF0091EA), + child: SingleChildScrollView( + physics: const AlwaysScrollableScrollPhysics(), + child: SizedBox( + height: MediaQuery.of(context).size.height * 0.6, + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.people_outline, + size: 80, + color: Colors.grey[400], + ), + const SizedBox(height: 16), + Text( + 'Belum ada data pasien', + style: TextStyle( + fontSize: 16, + color: Colors.grey[600], + ), + ), + const SizedBox(height: 8), + Text( + 'Tarik ke bawah untuk refresh', + style: TextStyle( + fontSize: 12, + color: Colors.grey[400], + ), + ), + ], + ), + ), + ), + ), + ); + } + + return RefreshIndicator( + onRefresh: () async { + controller.refreshPatients(); + await Future.delayed(const Duration(milliseconds: 500)); + }, + color: const Color(0xFF0091EA), + child: ListView.builder( + physics: const AlwaysScrollableScrollPhysics(), padding: const EdgeInsets.symmetric( horizontal: 20, vertical: 8, @@ -128,51 +171,64 @@ class ProfileView extends GetView { patient: patient, onEditTap: () { Get.dialog( - ModalPatientAddEdit( - patient: patient, - isEdit: true, - ), + ModalPatientAddEdit(patient: patient, isEdit: true), barrierDismissible: true, ); }, onDeleteTap: () { Get.dialog( AlertDialog( + backgroundColor: Colors.white, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(16), ), - title: const Text('Hapus Pasien'), + title: const Text( + 'Hapus Pasien', + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 18, + ), + ), content: Text( 'Apakah Anda yakin ingin menghapus ${patient.name}?', + style: TextStyle(fontSize: 16), ), actions: [ TextButton( onPressed: () => Get.back(), - child: Text( - 'Batal', - style: TextStyle(color: Colors.grey[700]), + style: TextButton.styleFrom( + foregroundColor: Colors.grey[700], + padding: const EdgeInsets.symmetric( + vertical: 12, + horizontal: 20, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), ), + child: const Text('Batal'), ), TextButton( onPressed: () { controller.deletePatient(patient.id); Get.back(); - Get.snackbar( - 'Berhasil', - 'Pasien berhasil dihapus', - snackPosition: SnackPosition.BOTTOM, - backgroundColor: Colors.redAccent, - colorText: Colors.white, - margin: const EdgeInsets.all(16), - icon: const Icon( - Icons.delete, - color: Colors.white, - ), - ); }, + style: TextButton.styleFrom( + foregroundColor: Colors.white, + backgroundColor: Colors.red, + padding: const EdgeInsets.symmetric( + vertical: 12, + horizontal: 20, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), child: const Text( 'Hapus', - style: TextStyle(color: Colors.red), + style: TextStyle( + fontWeight: FontWeight.bold, + ), ), ), ], @@ -181,13 +237,13 @@ class ProfileView extends GetView { }, ); }, - ); - }, - ), + ), + ); + }), ), ], ), ), ); } -} \ No newline at end of file +} diff --git a/lib/app/modules/schedule/controllers/schedule_controller.dart b/lib/app/modules/schedule/controllers/schedule_controller.dart index bebf9da..3b2e979 100644 --- a/lib/app/modules/schedule/controllers/schedule_controller.dart +++ b/lib/app/modules/schedule/controllers/schedule_controller.dart @@ -1,75 +1,219 @@ -// lib/app/modules/schedule/controllers/schedule_controller.dart import 'package:get/get.dart'; +import 'dart:async'; +import '../../../services/firestore_service.dart'; +import '../../../models/schedule_model.dart'; +import '../../../widgets/app_snackbar.dart'; class PatientSchedule { final String id; final String name; final String room; final String deviceId; - final String medicineType; - final String fluidType; final String medicineTime; final String fluidTime; + final String timeOfDay; PatientSchedule({ required this.id, required this.name, required this.room, required this.deviceId, - required this.medicineType, - required this.fluidType, required this.medicineTime, required this.fluidTime, + required this.timeOfDay, }); } class ScheduleController extends GetxController { + final FirestoreService _firestoreService = FirestoreService(); + final selectedTab = 0.obs; final selectedDate = DateTime.now().obs; + final allSchedules = [].obs; + final filteredSchedules = [].obs; + final isLoading = false.obs; - final schedules = [ - PatientSchedule( - id: '1', - name: 'Mrs. Shinta', - room: 'Ruang Mawar 002', - deviceId: 'SI001', - medicineType: 'Obat & Dosis', - fluidType: 'Cairan & Tetes', - medicineTime: 'Amoxicillin 3 ml', - fluidTime: 'Mnt 30 tetes /jam', - ), - PatientSchedule( - id: '2', - name: 'Mr. Parjo', - room: 'Ruang Melati 001', - deviceId: 'SI001', - medicineType: 'Obat & Dosis', - fluidType: 'Cairan & Tetes', - medicineTime: 'Amoxicillin 3 ml', - fluidTime: 'Mnt 30 tetes /jam', - ), - ].obs; + StreamSubscription? _schedulesSubscription; - void changeTab(int index) { + final Map _tabToTimeOfDay = { + 0: 'Pagi', + 1: 'Siang', + 2: 'Sore', + 3: 'Malam', + }; + + @override + void onInit() { + super.onInit(); + _loadSchedules(); + } + + @override + void onClose() { + _schedulesSubscription?.cancel(); + super.onClose(); + } + + void _loadSchedules() { + isLoading.value = true; + _schedulesSubscription?.cancel(); + + _schedulesSubscription = _firestoreService + .getSchedulesStream(date: selectedDate.value) + .listen( + (scheduleModels) async { + try { + final List items = []; + + 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, + name: patient.namePatient, + room: roomName, + deviceId: patient.deviceId, + medicineTime: scheduleModel.medicineDetail, + fluidTime: scheduleModel.fluidDetail, + timeOfDay: scheduleModel.timeOfDay, + ), + ); + } + } 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'); + }, + ); + } + + void _applyTimeFilter() { + final selectedTime = _tabToTimeOfDay[selectedTab.value]; + filteredSchedules.value = allSchedules + .where((schedule) => schedule.timeOfDay == selectedTime) + .toList(); + } + + Future _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; + } + } + + void changeTab(int index) { selectedTab.value = index; + _applyTimeFilter(); } void selectDate(DateTime date) { selectedDate.value = date; + _loadSchedules(); } - void addSchedule(PatientSchedule schedule) { - schedules.add(schedule); + void refreshSchedules() { + _loadSchedules(); } - void updateSchedule(String id, PatientSchedule updatedSchedule) { - final index = schedules.indexWhere((s) => s.id == id); - if (index != -1) { - schedules[index] = updatedSchedule; + Future 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, + medicineDetail: schedule.medicineTime, + fluidDetail: schedule.fluidTime, + scheduleDate: scheduleDate, + timeOfDay: schedule.timeOfDay, + createdAt: DateTime.now(), + ); + + await _firestoreService.addSchedule(scheduleModel); + AppSnackbar.success('Jadwal berhasil ditambahkan'); + } catch (e) { + AppSnackbar.error('Gagal menambahkan jadwal'); } } - void deleteSchedule(String id) { - schedules.removeWhere((s) => s.id == id); + Future updateSchedule(String id, 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, + medicineDetail: updatedSchedule.medicineTime, + fluidDetail: updatedSchedule.fluidTime, + scheduleDate: scheduleDate, + timeOfDay: updatedSchedule.timeOfDay, + createdAt: DateTime.now(), + ); + + await _firestoreService.updateSchedule(id, scheduleModel); + AppSnackbar.success('Jadwal berhasil diperbarui'); + } catch (e) { + AppSnackbar.error('Gagal memperbarui jadwal'); + } + } + + Future deleteSchedule(String id) async { + try { + await _firestoreService.deleteSchedule(id); + AppSnackbar.success('Jadwal berhasil dihapus'); + } catch (e) { + AppSnackbar.error('Gagal menghapus jadwal'); + } + } + + Future>> getPatientsList() async { + try { + final patients = await _firestoreService.getPatientsStream().first; + final List> result = []; + + for (var patient in patients) { + try { + final roomName = await _getRoomName(patient.roomId); + result.add({ + 'id': patient.id, + 'name': patient.namePatient, + 'room': roomName, + 'device': patient.deviceId, + }); + } catch (e) { + continue; + } + } + + return result; + } catch (e) { + return []; + } } } \ No newline at end of file diff --git a/lib/app/modules/schedule/views/schedule_view.dart b/lib/app/modules/schedule/views/schedule_view.dart index 7382841..1680d74 100644 --- a/lib/app/modules/schedule/views/schedule_view.dart +++ b/lib/app/modules/schedule/views/schedule_view.dart @@ -1,4 +1,3 @@ -// lib\app\modules\schedule\views\schedule_view.dart import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:intl/intl.dart'; @@ -12,235 +11,138 @@ class ScheduleView extends GetView { @override Widget build(BuildContext context) { Get.put(ScheduleController()); - return Scaffold( backgroundColor: Colors.white, body: SafeArea( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - // Header Section - Container( - color: Colors.white, - padding: const EdgeInsets.all(20), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Title - const Text( - 'Schedule Obat', - style: TextStyle( - fontSize: 24, - fontWeight: FontWeight.bold, - color: Color(0xFF424242), - ), - ), - const SizedBox(height: 16), - - // Date Picker & Add Button - Row( - children: [ - // Date Picker - Expanded( - child: GestureDetector( - onTap: () => _selectDate(context), - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 12, - ), - decoration: BoxDecoration( - color: Colors.grey[100], - borderRadius: BorderRadius.circular(12), - border: Border.all( - color: Colors.grey.shade300, - width: 1, - ), - ), - child: Row( - children: [ - const Icon( - Icons.calendar_today, - size: 18, - color: Color(0xFF0091EA), - ), - const SizedBox(width: 10), - Obx( - () => Text( - DateFormat( - 'EEEE, dd MMMM yyyy', - 'id_ID', - ).format(controller.selectedDate.value), - style: const TextStyle( - fontSize: 14, - fontWeight: FontWeight.w600, - color: Color(0xFF424242), - ), - ), - ), - const Spacer(), - Icon( - Icons.arrow_drop_down, - color: Colors.grey[600], - ), - ], - ), - ), - ), - ), - const SizedBox(width: 12), - - // Add Button - Container( - decoration: BoxDecoration( - gradient: const LinearGradient( - colors: [Color(0xFF0091EA), Color(0xFF0277BD)], - ), - borderRadius: BorderRadius.circular(12), - boxShadow: [ - BoxShadow( - color: const Color(0xFF0091EA).withOpacity(0.3), - blurRadius: 8, - offset: const Offset(0, 2), - ), - ], - ), - child: Material( - color: Colors.transparent, - child: // Ubah bagian tombol Add di schedule_view.dart: - InkWell( - onTap: () { - Get.dialog( - const ModalScheduleAddEdit(isEdit: false), - barrierDismissible: true, - ); - }, - borderRadius: BorderRadius.circular(12), - child: const Padding( - padding: EdgeInsets.all(12), - child: Icon( - Icons.add, - color: Colors.white, - size: 24, - ), - ), - ), - ), - ), - ], - ), - const SizedBox(height: 16), - - // Time Filter Tabs - Obx( - () => Row( - children: [ - Expanded(child: _buildTabButton('Pagi', 0)), - const SizedBox(width: 8), - Expanded(child: _buildTabButton('Siang', 1)), - const SizedBox(width: 8), - Expanded(child: _buildTabButton('Sore', 2)), - const SizedBox(width: 8), - Expanded(child: _buildTabButton('Malam', 3)), - ], - ), - ), - ], - ), - ), - + _buildHeader(context), const SizedBox(height: 12), - - // Schedule List - Expanded( - child: Obx(() { - if (controller.schedules.isEmpty) { - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - Icons.event_note, - size: 80, - color: Colors.grey[400], - ), - const SizedBox(height: 16), - Text( - 'Tidak ada jadwal', - style: TextStyle( - fontSize: 16, - color: Colors.grey[600], - ), - ), - ], - ), - ); - } - - return ListView.builder( - padding: const EdgeInsets.symmetric( - horizontal: 20, - vertical: 8, - ), - itemCount: controller.schedules.length, - itemBuilder: (context, index) { - final schedule = controller.schedules[index]; - return ScheduleCard( - schedule: schedule, - onEditTap: () { - Get.dialog( - ModalScheduleAddEdit( - schedule: schedule, - isEdit: true, - ), - barrierDismissible: true, - ); - }, - onDeleteTap: () { - Get.dialog( - AlertDialog( - title: const Text('Hapus Jadwal'), - content: const Text( - 'Apakah Anda yakin ingin menghapus jadwal ini?', - ), - actions: [ - TextButton( - onPressed: () => Get.back(), - child: const Text('Batal'), - ), - TextButton( - onPressed: () { - controller.deleteSchedule(schedule.id); - Get.back(); - Get.snackbar( - 'Berhasil', - 'Jadwal berhasil dihapus', - snackPosition: SnackPosition.BOTTOM, - backgroundColor: Colors.redAccent, - colorText: Colors.white, - margin: const EdgeInsets.all(16), - ); - }, - child: const Text( - 'Hapus', - style: TextStyle(color: Colors.red), - ), - ), - ], - ), - ); - }, - ); - }, - ); - }), - ), + Expanded(child: _buildScheduleList()), ], ), ), ); } + Widget _buildHeader(BuildContext context) { + return Container( + color: Colors.white, + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Schedule Obat', + style: TextStyle( + fontSize: 24, + fontWeight: FontWeight.bold, + color: Color(0xFF424242), + ), + ), + const SizedBox(height: 16), + Row( + children: [ + Expanded(child: _buildDatePicker(context)), + const SizedBox(width: 12), + _buildAddButton(), + ], + ), + const SizedBox(height: 16), + _buildTimeFilterTabs(), + ], + ), + ); + } + + Widget _buildDatePicker(BuildContext context) { + return GestureDetector( + onTap: () => _selectDate(context), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration( + color: Colors.grey[100], + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.grey.shade300), + ), + child: Row( + children: [ + const Icon( + Icons.calendar_today, + size: 18, + color: Color(0xFF0091EA), + ), + const SizedBox(width: 10), + Obx( + () => Text( + DateFormat( + 'EEEE, dd MMMM yyyy', + 'id_ID', + ).format(controller.selectedDate.value), + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: Color(0xFF424242), + ), + ), + ), + const Spacer(), + Icon(Icons.arrow_drop_down, color: Colors.grey[600]), + ], + ), + ), + ); + } + + Widget _buildAddButton() { + return Container( + decoration: BoxDecoration( + gradient: const LinearGradient( + colors: [Color(0xFF0091EA), Color(0xFF0277BD)], + ), + borderRadius: BorderRadius.circular(12), + boxShadow: [ + BoxShadow( + color: const Color(0xFF0091EA).withOpacity(0.3), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: () => Get.dialog( + const ModalScheduleAddEdit(isEdit: false), + barrierDismissible: true, + ), + borderRadius: BorderRadius.circular(12), + child: const Padding( + padding: EdgeInsets.all(12), + child: Icon(Icons.add, color: Colors.white, size: 24), + ), + ), + ), + ); + } + + Widget _buildTimeFilterTabs() { + return Obx( + () => Row( + children: [ + Expanded(child: _buildTabButton('Pagi', 0)), + const SizedBox(width: 8), + Expanded(child: _buildTabButton('Siang', 1)), + const SizedBox(width: 8), + Expanded(child: _buildTabButton('Sore', 2)), + const SizedBox(width: 8), + Expanded(child: _buildTabButton('Malam', 3)), + ], + ), + ); + } + Widget _buildTabButton(String title, int index) { final isSelected = controller.selectedTab.value == index; return GestureDetector( @@ -252,7 +154,6 @@ class ScheduleView extends GetView { borderRadius: BorderRadius.circular(10), border: Border.all( color: isSelected ? const Color(0xFF0091EA) : Colors.grey.shade300, - width: 1, ), ), child: Text( @@ -268,6 +169,122 @@ class ScheduleView extends GetView { ); } + Widget _buildScheduleList() { + return Obx(() { + if (controller.isLoading.value) { + return const Center( + child: CircularProgressIndicator(color: Color(0xFF0091EA)), + ); + } + + if (controller.filteredSchedules.isEmpty) { + return RefreshIndicator( + onRefresh: () async { + controller.refreshSchedules(); + await Future.delayed(const Duration(milliseconds: 500)); + }, + color: const Color(0xFF0091EA), + child: SingleChildScrollView( + physics: const AlwaysScrollableScrollPhysics(), + child: SizedBox( + height: MediaQuery.of(Get.context!).size.height * 0.6, + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.event_note, size: 80, color: Colors.grey[400]), + const SizedBox(height: 16), + Text( + 'Tidak ada jadwal', + style: TextStyle(fontSize: 16, color: Colors.grey[600]), + ), + const SizedBox(height: 8), + Text( + 'Tarik ke bawah untuk refresh', + style: TextStyle(fontSize: 12, color: Colors.grey[400]), + ), + ], + ), + ), + ), + ), + ); + } + + return RefreshIndicator( + onRefresh: () async { + controller.refreshSchedules(); + await Future.delayed(const Duration(milliseconds: 500)); + }, + color: const Color(0xFF0091EA), + child: ListView.builder( + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8), + itemCount: controller.filteredSchedules.length, + itemBuilder: (context, index) { + final schedule = controller.filteredSchedules[index]; + return ScheduleCard( + schedule: schedule, + onEditTap: () => Get.dialog( + ModalScheduleAddEdit(schedule: schedule, isEdit: true), + barrierDismissible: true, + ), + onDeleteTap: () => _showDeleteDialog(schedule.id), + ); + }, + ), + ); + }); + } + + void _showDeleteDialog(String scheduleId) { + Get.dialog( + AlertDialog( + backgroundColor: Colors.white, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), + title: const Text( + 'Hapus Jadwal', + style: TextStyle(fontWeight: FontWeight.bold, fontSize: 18), + ), + content: const Text( + 'Yakin ingin menghapus jadwal ini?', + style: TextStyle(fontSize: 16), + ), + actions: [ + TextButton( + onPressed: () => Get.back(), + style: TextButton.styleFrom( + foregroundColor: Colors.blue, + padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 20), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + child: const Text('Batal'), + ), + TextButton( + onPressed: () { + controller.deleteSchedule(scheduleId); + Get.back(); + }, + style: TextButton.styleFrom( + foregroundColor: Colors.white, + backgroundColor: Colors.red, + padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 20), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + child: const Text( + 'Hapus', + style: TextStyle(fontWeight: FontWeight.bold), + ), + ), + ], + ), + ); + } + Future _selectDate(BuildContext context) async { final DateTime? picked = await showDatePicker( context: context, @@ -287,8 +304,6 @@ class ScheduleView extends GetView { ); }, ); - if (picked != null) { - controller.selectDate(picked); - } + if (picked != null) controller.selectDate(picked); } } diff --git a/lib/app/modules/splash-screen/controllers/splash_screen_controller.dart b/lib/app/modules/splash-screen/controllers/splash_screen_controller.dart index a87713d..616649e 100644 --- a/lib/app/modules/splash-screen/controllers/splash_screen_controller.dart +++ b/lib/app/modules/splash-screen/controllers/splash_screen_controller.dart @@ -1,16 +1,28 @@ -// lib\app\modules\splash-screen\controllers\splash_screen_controller.dart import 'package:get/get.dart'; +import 'package:firebase_auth/firebase_auth.dart'; import '../../../routes/app_pages.dart'; class SplashScreenController extends GetxController { + final FirebaseAuth _auth = FirebaseAuth.instance; + @override void onInit() { super.onInit(); - _navigateToLogin(); + _checkAuthStatus(); } - void _navigateToLogin() async { + void _checkAuthStatus() async { await Future.delayed(const Duration(seconds: 4)); - Get.offNamed(Routes.LOGIN); + + // Cek apakah user sudah login + User? user = _auth.currentUser; + + if (user != null) { + // User sudah login, langsung ke home/navbar + Get.offAllNamed(Routes.NAVBAR); + } else { + // User belum login, ke halaman login + Get.offAllNamed(Routes.LOGIN); + } } } \ No newline at end of file diff --git a/lib/app/services/firestore_service.dart b/lib/app/services/firestore_service.dart new file mode 100644 index 0000000..175ae6d --- /dev/null +++ b/lib/app/services/firestore_service.dart @@ -0,0 +1,467 @@ +import 'package:cloud_firestore/cloud_firestore.dart'; +import 'package:firebase_auth/firebase_auth.dart'; +import '../models/patient_model.dart'; +import '../models/device_model.dart'; +import '../models/room_model.dart'; +import '../models/notification_model.dart'; +import '../models/schedule_model.dart'; +import '../models/history_model.dart'; +import '../models/user_model.dart'; + +class FirestoreService { + final FirebaseFirestore _firestore = FirebaseFirestore.instance; + final FirebaseAuth _auth = FirebaseAuth.instance; + + // ==================== USER MANAGEMENT ==================== + + Future userExists(String email) async { + try { + final snapshot = await _firestore + .collection('users') + .where('email', isEqualTo: email) + .limit(1) + .get(); + return snapshot.docs.isNotEmpty; + } catch (e) { + return false; + } + } + + Future createUser({ + required String email, + required String password, + required String name, + required String role, + String? deviceId, + }) async { + final userCredential = await _auth.createUserWithEmailAndPassword( + email: email, + password: password, + ); + + final uid = userCredential.user!.uid; + + await _firestore.collection('users').doc(uid).set({ + 'email': email, + 'name': name, + 'role': role, + if (deviceId != null) 'device_id': deviceId, // TAMBAH device_id + 'created_at': FieldValue.serverTimestamp(), + }); + + await _auth.signOut(); + return uid; + } + + Future updateUser({ + required String uid, + required String name, + String? email, + String? deviceId, + }) async { + final updateData = {'name': name}; + if (email != null) updateData['email'] = email; + if (deviceId != null) + updateData['device_id'] = deviceId; // Support update device_id + await _firestore.collection('users').doc(uid).update(updateData); + } + + Future deleteUser(String uid) async { + await _firestore.collection('users').doc(uid).delete(); + } + + Future deleteUserFromAuth(String email, String password) async { + try { + // Sign in temporary dengan user yang akan dihapus + final userCredential = await _auth.signInWithEmailAndPassword( + email: email, + password: password, + ); + + // Hapus user dari Authentication + await userCredential.user?.delete(); + + // Sign out + await _auth.signOut(); + } catch (e) { + print('Error deleting user from auth: $e'); + // Jika gagal, ignore error (misalnya user sudah tidak ada) + } + } + + Future getUserByEmail(String email) async { + try { + final snapshot = await _firestore + .collection('users') + .where('email', isEqualTo: email) + .limit(1) + .get(); + return snapshot.docs.isNotEmpty ? snapshot.docs.first : null; + } catch (e) { + return null; + } + } + + Future getUserModelByEmail(String email) async { + try { + final snapshot = await _firestore + .collection('users') + .where('email', isEqualTo: email) + .limit(1) + .get(); + return snapshot.docs.isNotEmpty + ? UserModel.fromFirestore(snapshot.docs.first) + : null; + } catch (e) { + return null; + } + } + + // ==================== DEVICE MANAGEMENT ==================== + + Future deviceExists(String deviceId) async { + try { + final doc = await _firestore.collection('devices').doc(deviceId).get(); + return doc.exists; + } catch (e) { + return false; + } + } + + Future patientExistsByDevice(String deviceId) async { + try { + final snapshot = await _firestore + .collection('patients') + .where('device_id', isEqualTo: deviceId) + .limit(1) + .get(); + return snapshot.docs.isNotEmpty; + } catch (e) { + return false; + } + } + + Stream> getDevicesStream() { + return _firestore + .collection('devices') + .snapshots() + .map( + (snapshot) => snapshot.docs + .map((doc) => DeviceModel.fromFirestore(doc)) + .toList(), + ); + } + + Future getDeviceById(String deviceId) async { + try { + final doc = await _firestore.collection('devices').doc(deviceId).get(); + return doc.exists ? DeviceModel.fromFirestore(doc) : null; + } catch (e) { + return null; + } + } + + Future createDevice(DeviceModel device) async { + await _firestore + .collection('devices') + .doc(device.idDevices) + .set(device.toFirestore()); + } + + Future deleteDevice(String deviceId) async { + await _firestore.collection('devices').doc(deviceId).delete(); + } + + // ==================== PATIENT MANAGEMENT ==================== + + Stream> getPatientsStream() { + return _firestore + .collection('patients') + .snapshots() + .map( + (snapshot) => snapshot.docs + .map((doc) => PatientModel.fromFirestore(doc)) + .toList(), + ); + } + + Future getPatientById(String patientId) async { + try { + final doc = await _firestore.collection('patients').doc(patientId).get(); + return doc.exists ? PatientModel.fromFirestore(doc) : null; + } catch (e) { + return null; + } + } + + Future getPatientByDeviceId(String deviceId) async { + try { + final snapshot = await _firestore + .collection('patients') + .where('device_id', isEqualTo: deviceId) + .limit(1) + .get(); + return snapshot.docs.isNotEmpty + ? PatientModel.fromFirestore(snapshot.docs.first) + : null; + } catch (e) { + return null; + } + } + + Future createPatient(PatientModel patient) async { + await _firestore.collection('patients').add(patient.toFirestore()); + } + + Future updatePatient(String patientId, PatientModel patient) async { + await _firestore + .collection('patients') + .doc(patientId) + .update(patient.toFirestore()); + } + + Future deletePatient(String patientId) async { + await _firestore.collection('patients').doc(patientId).delete(); + } + + // ==================== ROOM MANAGEMENT ==================== + + Future roomExists(String roomId) async { + try { + final doc = await _firestore.collection('rooms').doc(roomId).get(); + return doc.exists; + } catch (e) { + return false; + } + } + + Stream> getRoomsStream() { + return _firestore + .collection('rooms') + .snapshots() + .map( + (snapshot) => + snapshot.docs.map((doc) => RoomModel.fromFirestore(doc)).toList(), + ); + } + + Future getRoomById(String roomId) async { + try { + final doc = await _firestore.collection('rooms').doc(roomId).get(); + return doc.exists ? RoomModel.fromFirestore(doc) : null; + } catch (e) { + return null; + } + } + + Future createRoom(RoomModel room) async { + await _firestore.collection('rooms').doc(room.id).set(room.toFirestore()); + } + + Future deleteRoom(String roomId) async { + await _firestore.collection('rooms').doc(roomId).delete(); + } + + // ==================== HISTORY MANAGEMENT ==================== + + Stream> getDeviceHistoriesStream(String deviceId) { + return _firestore + .collection('histories') + .where('device_id', isEqualTo: deviceId) + .snapshots() + .map((snapshot) { + final histories = snapshot.docs + .map((doc) => HistoryModel.fromFirestore(doc)) + .toList(); + + histories.sort((a, b) => b.timestamp.compareTo(a.timestamp)); + + return histories; + }); + } + + Future> getDeviceHistories( + String deviceId, { + int limit = 100, + }) async { + try { + final snapshot = await _firestore + .collection('histories') + .where('device_id', isEqualTo: deviceId) + .get(); + + final histories = snapshot.docs + .map((doc) => HistoryModel.fromFirestore(doc)) + .toList(); + + histories.sort((a, b) => b.timestamp.compareTo(a.timestamp)); + + return histories.take(limit).toList(); + } catch (e) { + return []; + } + } + + Future> getDeviceHistoriesInRange( + String deviceId, + DateTime startDate, + DateTime endDate, + ) async { + try { + final snapshot = await _firestore + .collection('histories') + .where('device_id', isEqualTo: deviceId) + .where('timestamp', isGreaterThanOrEqualTo: startDate) + .where('timestamp', isLessThanOrEqualTo: endDate) + .get(); + + final histories = snapshot.docs + .map((doc) => HistoryModel.fromFirestore(doc)) + .toList(); + + histories.sort((a, b) => b.timestamp.compareTo(a.timestamp)); + + return histories; + } catch (e) { + return []; + } + } + + Future> getDeviceHistoriesToday(String deviceId) async { + final now = DateTime.now(); + final startOfDay = DateTime(now.year, now.month, now.day); + final endOfDay = DateTime(now.year, now.month, now.day, 23, 59, 59); + return getDeviceHistoriesInRange(deviceId, startOfDay, endOfDay); + } + + Future addHistory(HistoryModel history) async { + await _firestore.collection('histories').add(history.toFirestore()); + } + + Future deleteOldHistories({int daysToKeep = 30}) async { + final cutoffDate = DateTime.now().subtract(Duration(days: daysToKeep)); + final snapshot = await _firestore + .collection('histories') + .where('timestamp', isLessThan: cutoffDate) + .get(); + + final batch = _firestore.batch(); + for (var doc in snapshot.docs) { + batch.delete(doc.reference); + } + await batch.commit(); + } + + // BARU: Hapus semua histories berdasarkan device_id + Future deleteHistoriesByDevice(String deviceId) async { + final snapshot = await _firestore + .collection('histories') + .where('device_id', isEqualTo: deviceId) + .get(); + + final batch = _firestore.batch(); + for (var doc in snapshot.docs) { + batch.delete(doc.reference); + } + await batch.commit(); + } + + // ==================== NOTIFICATION MANAGEMENT ==================== + + Stream> getNotificationsStream() { + return _firestore + .collection('notifications') + .orderBy('created_at', descending: true) + .snapshots() + .map( + (snapshot) => snapshot.docs + .map((doc) => NotificationModel.fromFirestore(doc)) + .toList(), + ); + } + + Future addNotification(NotificationModel notification) async { + await _firestore + .collection('notifications') + .add(notification.toFirestore()); + } + + Future markNotificationAsRead(String notificationId) async { + await _firestore.collection('notifications').doc(notificationId).update({ + 'is_read': true, + }); + } + + Future deleteNotification(String notificationId) async { + await _firestore.collection('notifications').doc(notificationId).delete(); + } + + // BARU: Hapus semua notifications berdasarkan device_id + Future deleteNotificationsByDevice(String deviceId) async { + final snapshot = await _firestore + .collection('notifications') + .where('device_id', isEqualTo: deviceId) + .get(); + + final batch = _firestore.batch(); + for (var doc in snapshot.docs) { + batch.delete(doc.reference); + } + await batch.commit(); + } + + // ==================== SCHEDULE MANAGEMENT ==================== + + Stream> getSchedulesStream({ + DateTime? date, + String? timeOfDay, + }) { + Query query = _firestore.collection('schedules'); + + if (date != null) { + final startOfDay = DateTime(date.year, date.month, date.day); + final endOfDay = DateTime(date.year, date.month, date.day, 23, 59, 59); + query = query + .where('schedule_date', isGreaterThanOrEqualTo: startOfDay) + .where('schedule_date', isLessThanOrEqualTo: endOfDay); + } + + if (timeOfDay != null) { + query = query.where('time_of_day', isEqualTo: timeOfDay); + } + + return query.snapshots().map( + (snapshot) => + snapshot.docs.map((doc) => ScheduleModel.fromFirestore(doc)).toList(), + ); + } + + Future addSchedule(ScheduleModel schedule) async { + await _firestore.collection('schedules').add(schedule.toFirestore()); + } + + Future updateSchedule(String scheduleId, ScheduleModel schedule) async { + await _firestore + .collection('schedules') + .doc(scheduleId) + .update(schedule.toFirestore()); + } + + Future deleteSchedule(String scheduleId) async { + await _firestore.collection('schedules').doc(scheduleId).delete(); + } + + // BARU: Hapus semua schedules berdasarkan device_id + Future deleteSchedulesByDevice(String deviceId) async { + final snapshot = await _firestore + .collection('schedules') + .where('device_id', isEqualTo: deviceId) + .get(); + + final batch = _firestore.batch(); + for (var doc in snapshot.docs) { + batch.delete(doc.reference); + } + await batch.commit(); + } +} diff --git a/lib/app/services/notification_service.dart b/lib/app/services/notification_service.dart new file mode 100644 index 0000000..e69de29 diff --git a/lib/app/services/realtime_database_service.dart b/lib/app/services/realtime_database_service.dart new file mode 100644 index 0000000..2252e72 --- /dev/null +++ b/lib/app/services/realtime_database_service.dart @@ -0,0 +1,176 @@ +import 'package:firebase_database/firebase_database.dart'; +import '../models/realtime_monitoring_model.dart'; + +class RealtimeDatabaseService { + final DatabaseReference _database = FirebaseDatabase.instance.ref(); + + // ==================== MONITORING ==================== + + Stream> getRoomDevicesStream( + String roomId, + ) { + return _database + .child('realtime/rooms/$roomId/devices') + .onValue + .map((event) { + final Map devices = {}; + + if (event.snapshot.value != null) { + final data = event.snapshot.value as Map; + data.forEach((deviceId, deviceData) { + if (deviceData is Map) { + devices[deviceId] = RealtimeMonitoringModel.fromRealtimeDB( + deviceId, + roomId, + deviceData as Map, + ); + } + }); + } + + return devices; + }); + } + + Stream getDeviceStream( + String roomId, + String deviceId, + ) { + return _database + .child('realtime/rooms/$roomId/devices/$deviceId') + .onValue + .map((event) { + if (event.snapshot.value != null) { + final data = event.snapshot.value as Map; + return RealtimeMonitoringModel.fromRealtimeDB(deviceId, roomId, data); + } + return null; + }); + } + + Stream> getAllDevicesStream() { + return _database.child('realtime/rooms').onValue.map((event) { + final List allDevices = []; + + if (event.snapshot.value != null) { + final rooms = event.snapshot.value as Map; + + rooms.forEach((roomId, roomData) { + if (roomData is Map && roomData['devices'] != null) { + final devices = roomData['devices'] as Map; + + devices.forEach((deviceId, deviceData) { + if (deviceData is Map) { + allDevices.add( + RealtimeMonitoringModel.fromRealtimeDB( + deviceId, + roomId, + deviceData as Map, + ), + ); + } + }); + } + }); + } + + return allDevices; + }); + } + + Future getDevice( + String roomId, + String deviceId, + ) async { + try { + final snapshot = + await _database.child('realtime/rooms/$roomId/devices/$deviceId').get(); + + if (snapshot.value != null) { + final data = snapshot.value as Map; + return RealtimeMonitoringModel.fromRealtimeDB(deviceId, roomId, data); + } + return null; + } catch (e) { + return null; + } + } + + // ==================== CONTROL ==================== + + Future controlServo(String roomId, String deviceId, bool open) async { + await _database + .child('realtime/rooms/$roomId/devices/$deviceId/controlling') + .update({ + 'servo_open': open, + 'last_command': DateTime.now().toIso8601String(), + }); + } + + Future updateDropRate( + String roomId, + String deviceId, + double dropRate, + ) async { + await _database + .child('realtime/rooms/$roomId/devices/$deviceId/monitoring') + .update({ + 'drop_rate': dropRate, + 'last_update': DateTime.now().toIso8601String(), + }); + } + + Future updateDeviceStatus( + String roomId, + String deviceId, + String status, + ) async { + await _database + .child('realtime/rooms/$roomId/devices/$deviceId/monitoring') + .update({ + 'device_status': status, + 'last_update': DateTime.now().toIso8601String(), + }); + } + + Future updateServoSettings( + String roomId, + String deviceId, + int angleOpen, + int angleClose, + ) async { + await _database + .child('realtime/rooms/$roomId/devices/$deviceId/settings') + .update({ + 'servo_angle_open': angleOpen, + 'servo_angle_close': angleClose, + }); + } + + // ==================== UTILITY ==================== + + Future isDeviceConnected(String roomId, String deviceId) async { + try { + final device = await getDevice(roomId, deviceId); + return device?.deviceStatus == 'connected'; + } catch (e) { + return false; + } + } + + Future getDeviceDropRate(String roomId, String deviceId) async { + try { + final snapshot = await _database + .child( + 'realtime/rooms/$roomId/devices/$deviceId/monitoring/drop_rate') + .get(); + + if (snapshot.value != null) { + return (snapshot.value as num).toDouble(); + } + return 0.0; + } catch (e) { + return 0.0; + } + } +} \ No newline at end of file diff --git a/lib/app/widgets/app_snackbar.dart b/lib/app/widgets/app_snackbar.dart new file mode 100644 index 0000000..6d95033 --- /dev/null +++ b/lib/app/widgets/app_snackbar.dart @@ -0,0 +1,48 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; + +class AppSnackbar { + static void success(String message) { + Get.snackbar( + 'Berhasil', + message, + snackPosition: SnackPosition.BOTTOM, + backgroundColor: const Color(0xFF4CAF50), + colorText: Colors.white, + duration: const Duration(seconds: 3), + ); + } + + static void error(String message) { + Get.snackbar( + 'Error', + message, + snackPosition: SnackPosition.BOTTOM, + backgroundColor: Colors.redAccent, + colorText: Colors.white, + duration: const Duration(seconds: 3), + ); + } + + static void warning(String message) { + Get.snackbar( + 'Peringatan', + message, + snackPosition: SnackPosition.BOTTOM, + backgroundColor: Colors.orangeAccent, + colorText: Colors.white, + duration: const Duration(seconds: 3), + ); + } + + static void info(String message) { + Get.snackbar( + 'Info', + message, + snackPosition: SnackPosition.BOTTOM, + backgroundColor: Colors.blueAccent, + colorText: Colors.white, + duration: const Duration(seconds: 3), + ); + } +} \ No newline at end of file diff --git a/lib/app/widgets/history_grafik_card.dart b/lib/app/widgets/history_grafik_card.dart index 62ccb94..0881a70 100644 --- a/lib/app/widgets/history_grafik_card.dart +++ b/lib/app/widgets/history_grafik_card.dart @@ -1,10 +1,9 @@ -// lib/app/widgets/history_grafik_card.dart import 'package:flutter/material.dart'; import 'package:fl_chart/fl_chart.dart'; -import '../models/chart_data_point.dart'; // Import model yang terpisah +import '../models/chart_data_point.dart'; class HistoryGrafikCard extends StatelessWidget { - final List dataPoints; // Ubah dari dynamic ke List + final List dataPoints; final VoidCallback? onBookmarkTap; const HistoryGrafikCard({ @@ -15,6 +14,40 @@ class HistoryGrafikCard extends StatelessWidget { @override Widget build(BuildContext context) { + if (dataPoints.isEmpty) { + return Container( + padding: const EdgeInsets.all(32), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.grey[200]!), + ), + child: Center( + child: Column( + children: [ + Icon(Icons.show_chart, size: 48, color: Colors.grey[400]), + const SizedBox(height: 12), + Text( + 'Belum ada data history', + style: TextStyle(fontSize: 14, color: Colors.grey[600]), + ), + ], + ), + ), + ); + } + + 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 minY = (drops.reduce((a, b) => a < b ? a : b) - 5.0).clamp( + 0.0, + double.infinity, + ); + final maxY = drops.reduce((a, b) => a > b ? a : b) + 5.0; + return Container( padding: const EdgeInsets.all(16), decoration: BoxDecoration( @@ -33,34 +66,27 @@ class HistoryGrafikCard extends StatelessWidget { child: Column( children: [ Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Row( - children: [ - Container( - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - color: const Color(0xFFE3F2FD), - borderRadius: BorderRadius.circular(8), - ), - child: const Icon( - Icons.insert_chart_outlined, - size: 20, - color: Color(0xFF2196F3), - ), - ), - const SizedBox(width: 12), - const Text( - 'Grafik Infus Pasien', - style: TextStyle(fontSize: 15, fontWeight: FontWeight.bold), - ), - ], + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: const Color(0xFFE3F2FD), + borderRadius: BorderRadius.circular(8), + ), + child: const Icon( + Icons.insert_chart_outlined, + size: 20, + color: Color(0xFF2196F3), + ), + ), + const SizedBox(width: 12), + const Text( + 'Grafik Infus Pasien', + style: TextStyle(fontSize: 15, fontWeight: FontWeight.bold), ), - ], ), const SizedBox(height: 20), - // FL Chart SizedBox( height: 180, child: LineChart( @@ -68,7 +94,7 @@ class HistoryGrafikCard extends StatelessWidget { gridData: FlGridData( show: true, drawVerticalLine: false, - horizontalInterval: 5, + horizontalInterval: (maxY - minY) / 4, getDrawingHorizontalLine: (value) { return FlLine(color: Colors.grey[200]!, strokeWidth: 1); }, @@ -85,7 +111,7 @@ class HistoryGrafikCard extends StatelessWidget { sideTitles: SideTitles( showTitles: true, reservedSize: 30, - interval: 1, + interval: dataPoints.length > 5 ? 2.0 : 1.0, getTitlesWidget: (double value, TitleMeta meta) { return Padding( padding: const EdgeInsets.only(top: 8.0), @@ -104,7 +130,7 @@ class HistoryGrafikCard extends StatelessWidget { leftTitles: AxisTitles( sideTitles: SideTitles( showTitles: true, - interval: 5, + interval: (maxY - minY) / 4, reservedSize: 35, getTitlesWidget: (double value, TitleMeta meta) { return Text( @@ -126,17 +152,17 @@ class HistoryGrafikCard extends StatelessWidget { bottom: BorderSide(color: Colors.grey[300]!), ), ), - minX: 8, - maxX: 17, - minY: 20, - maxY: 40, + minX: minX, + maxX: maxX, + minY: minY, + maxY: maxY, lineBarsData: [ LineChartBarData( spots: dataPoints .map( (point) => FlSpot( point.hour.toDouble(), - point.dropsPerMinute, + point.dropsPerMinute.toDouble(), ), ) .toList(), diff --git a/lib/app/widgets/modal_patient_add_edit.dart b/lib/app/widgets/modal_patient_add_edit.dart index d2899b3..6be83af 100644 --- a/lib/app/widgets/modal_patient_add_edit.dart +++ b/lib/app/widgets/modal_patient_add_edit.dart @@ -1,4 +1,3 @@ -// lib/app/widgets/modal_patient_add_edit.dart import 'package:flutter/material.dart'; import 'package:get/get.dart'; import '../modules/profile/controllers/profile_controller.dart'; @@ -7,11 +6,7 @@ class ModalPatientAddEdit extends StatefulWidget { final Patient? patient; final bool isEdit; - const ModalPatientAddEdit({ - super.key, - this.patient, - this.isEdit = false, - }); + const ModalPatientAddEdit({super.key, this.patient, this.isEdit = false}); @override State createState() => _ModalPatientAddEditState(); @@ -24,22 +19,25 @@ class _ModalPatientAddEditState extends State { late TextEditingController _roomController; late TextEditingController _deviceIdController; late TextEditingController _guardianController; + late TextEditingController _emailController; + late TextEditingController _passwordController; + + bool _isSaving = false; + bool _obscurePassword = true; @override void initState() { super.initState(); - _nameController = TextEditingController( - text: widget.patient?.name ?? '', - ); - _roomController = TextEditingController( - text: widget.patient?.room ?? '', - ); - _deviceIdController = TextEditingController( - text: widget.patient?.deviceId ?? '', - ); - _guardianController = TextEditingController( - text: widget.patient?.guardian ?? '', - ); + _initControllers(); + } + + void _initControllers() { + _nameController = TextEditingController(text: widget.patient?.name ?? ''); + _roomController = TextEditingController(text: widget.patient?.room ?? ''); + _deviceIdController = TextEditingController(text: widget.patient?.deviceId ?? ''); + _guardianController = TextEditingController(text: widget.patient?.guardian ?? ''); + _emailController = TextEditingController(text: widget.patient?.email ?? ''); + _passwordController = TextEditingController(); } @override @@ -48,6 +46,8 @@ class _ModalPatientAddEditState extends State { _roomController.dispose(); _deviceIdController.dispose(); _guardianController.dispose(); + _emailController.dispose(); + _passwordController.dispose(); super.dispose(); } @@ -58,221 +58,336 @@ class _ModalPatientAddEditState extends State { insetPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 40), child: Container( constraints: const BoxConstraints(maxWidth: 500), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(24), - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(0.2), - blurRadius: 20, - offset: const Offset(0, 10), - ), - ], - ), + decoration: _dialogDecoration(), child: Column( mainAxisSize: MainAxisSize.min, children: [ - // Header - Container( - padding: const EdgeInsets.all(24), - decoration: BoxDecoration( - gradient: const LinearGradient( - colors: [Color(0xFF0091EA), Color(0xFF0277BD)], - ), - borderRadius: const BorderRadius.only( - topLeft: Radius.circular(24), - topRight: Radius.circular(24), - ), - ), - child: Row( - children: [ - Container( - padding: const EdgeInsets.all(10), - decoration: BoxDecoration( - color: Colors.white.withOpacity(0.2), - borderRadius: BorderRadius.circular(12), - ), - child: const Icon( - Icons.person_add, - color: Colors.white, - size: 24, - ), - ), - const SizedBox(width: 16), - Expanded( - child: Text( - widget.isEdit ? 'Edit Pasien' : 'Tambah Pasien Baru', - style: const TextStyle( - fontSize: 20, - fontWeight: FontWeight.bold, - color: Colors.white, - ), - ), - ), - IconButton( - onPressed: () => Get.back(), - icon: const Icon(Icons.close, color: Colors.white), - splashRadius: 20, - ), - ], - ), - ), - - // Form Content - Flexible( - child: SingleChildScrollView( - padding: const EdgeInsets.all(24), - child: Form( - key: _formKey, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Nama Pasien - _buildLabel('Nama Pasien', Icons.person), - const SizedBox(height: 8), - _buildTextField( - controller: _nameController, - hint: 'Contoh: Mrs. Shinta', - icon: Icons.person_outline, - validator: (value) { - if (value == null || value.isEmpty) { - return 'Nama pasien harus diisi'; - } - return null; - }, - ), - const SizedBox(height: 20), - - // Ruangan - _buildLabel('Ruangan', Icons.meeting_room), - const SizedBox(height: 8), - _buildTextField( - controller: _roomController, - hint: 'Contoh: Ruang Mawar 002', - icon: Icons.meeting_room_outlined, - validator: (value) { - if (value == null || value.isEmpty) { - return 'Ruangan harus diisi'; - } - return null; - }, - ), - const SizedBox(height: 20), - - // Device ID - _buildLabel('Device ID', Icons.devices), - const SizedBox(height: 8), - _buildTextField( - controller: _deviceIdController, - hint: 'Contoh: SI001', - icon: Icons.router_outlined, - validator: (value) { - if (value == null || value.isEmpty) { - return 'Device ID harus diisi'; - } - return null; - }, - ), - const SizedBox(height: 20), - - // Wali Pasien - _buildLabel('Wali Pasien', Icons.family_restroom), - const SizedBox(height: 8), - _buildTextField( - controller: _guardianController, - hint: 'Contoh: Budi Santoso', - icon: Icons.family_restroom_outlined, - validator: (value) { - if (value == null || value.isEmpty) { - return 'Wali pasien harus diisi'; - } - return null; - }, - ), - ], - ), - ), - ), - ), - - // Bottom Buttons - Container( - padding: const EdgeInsets.all(24), - decoration: BoxDecoration( - color: Colors.grey[50], - borderRadius: const BorderRadius.only( - bottomLeft: Radius.circular(24), - bottomRight: Radius.circular(24), - ), - ), - child: Row( - children: [ - Expanded( - child: OutlinedButton( - onPressed: () => Get.back(), - style: OutlinedButton.styleFrom( - padding: const EdgeInsets.symmetric(vertical: 16), - side: BorderSide(color: Colors.grey.shade300, width: 2), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - ), - child: Text( - 'Batal', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w600, - color: Colors.grey[700], - ), - ), - ), - ), - const SizedBox(width: 12), - Expanded( - child: Container( - decoration: BoxDecoration( - gradient: const LinearGradient( - colors: [Color(0xFF0091EA), Color(0xFF0277BD)], - ), - borderRadius: BorderRadius.circular(12), - boxShadow: [ - BoxShadow( - color: const Color(0xFF0091EA).withOpacity(0.4), - blurRadius: 8, - offset: const Offset(0, 4), - ), - ], - ), - child: ElevatedButton( - onPressed: _handleSave, - style: ElevatedButton.styleFrom( - backgroundColor: Colors.transparent, - shadowColor: Colors.transparent, - padding: const EdgeInsets.symmetric(vertical: 16), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - ), - child: Text( - widget.isEdit ? 'Simpan' : 'Tambah', - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.w600, - color: Colors.white, - ), - ), - ), - ), - ), - ], - ), - ), + _buildHeader(), + _buildForm(), + _buildFooter(), ], ), ), ); } + BoxDecoration _dialogDecoration() { + return BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(24), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.2), + blurRadius: 20, + offset: const Offset(0, 10), + ), + ], + ); + } + + Widget _buildHeader() { + return Container( + padding: const EdgeInsets.all(24), + decoration: const BoxDecoration( + gradient: LinearGradient( + colors: [Color(0xFF0091EA), Color(0xFF0277BD)], + ), + borderRadius: BorderRadius.only( + topLeft: Radius.circular(24), + topRight: Radius.circular(24), + ), + ), + child: Row( + children: [ + _buildHeaderIcon(), + const SizedBox(width: 16), + Expanded( + child: Text( + widget.isEdit ? 'Edit Pasien' : 'Tambah Pasien Baru', + style: const TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + color: Colors.white, + ), + ), + ), + IconButton( + onPressed: _isSaving ? null : () => Get.back(), + icon: const Icon(Icons.close, color: Colors.white), + splashRadius: 20, + ), + ], + ), + ); + } + + Widget _buildHeaderIcon() { + return Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.2), + borderRadius: BorderRadius.circular(12), + ), + child: Icon( + widget.isEdit ? Icons.edit : Icons.person_add, + color: Colors.white, + size: 24, + ), + ); + } + + Widget _buildForm() { + return Flexible( + child: SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildNameField(), + const SizedBox(height: 20), + _buildGuardianField(), + const SizedBox(height: 20), + _buildEmailField(), + if (!widget.isEdit) ...[ + const SizedBox(height: 20), + _buildPasswordField(), + ], + const SizedBox(height: 20), + _buildRoomField(), + const SizedBox(height: 20), + _buildDeviceIdField(), + ], + ), + ), + ), + ); + } + + Widget _buildNameField() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildLabel('Nama Pasien', Icons.person), + const SizedBox(height: 8), + _buildTextField( + controller: _nameController, + hint: 'Contoh: Mrs. Shinta', + icon: Icons.person_outline, + validator: (value) => value?.isEmpty ?? true ? 'Wajib diisi' : null, + ), + ], + ); + } + + Widget _buildGuardianField() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildLabel('Nama Wali', Icons.family_restroom), + const SizedBox(height: 8), + _buildTextField( + controller: _guardianController, + hint: 'Contoh: Budi Santoso', + icon: Icons.family_restroom_outlined, + validator: (value) => value?.isEmpty ?? true ? 'Wajib diisi' : null, + ), + ], + ); + } + + Widget _buildEmailField() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildLabel('Email Wali', Icons.email), + const SizedBox(height: 8), + _buildTextField( + controller: _emailController, + hint: 'Contoh: wali@example.com', + icon: Icons.email_outlined, + keyboardType: TextInputType.emailAddress, + enabled: !widget.isEdit, + validator: (value) { + if (widget.isEdit) return null; + if (value?.isEmpty ?? true) return 'Wajib diisi'; + if (!GetUtils.isEmail(value!)) return 'Email tidak valid'; + return null; + }, + ), + if (widget.isEdit) + Padding( + padding: const EdgeInsets.only(top: 4), + child: Text( + 'Email tidak dapat diubah', + style: TextStyle( + fontSize: 12, + color: Colors.orange[700], + fontStyle: FontStyle.italic, + ), + ), + ), + ], + ); + } + + Widget _buildPasswordField() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildLabel('Password', Icons.lock), + const SizedBox(height: 8), + _buildTextField( + controller: _passwordController, + hint: 'Minimal 6 karakter', + icon: Icons.lock_outline, + obscureText: _obscurePassword, + suffixIcon: IconButton( + icon: Icon( + _obscurePassword ? Icons.visibility_outlined : Icons.visibility_off_outlined, + color: Colors.grey[600], + ), + onPressed: () => setState(() => _obscurePassword = !_obscurePassword), + ), + validator: (value) { + if (value?.isEmpty ?? true) return 'Wajib diisi'; + if (value!.length < 6) return 'Minimal 6 karakter'; + return null; + }, + ), + ], + ); + } + + Widget _buildRoomField() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildLabel('Ruangan', Icons.meeting_room), + const SizedBox(height: 8), + _buildTextField( + controller: _roomController, + hint: 'Contoh: Ruang Mawar', + icon: Icons.meeting_room_outlined, + validator: (value) => value?.isEmpty ?? true ? 'Wajib diisi' : null, + ), + const SizedBox(height: 4), + Text( + 'ID otomatis: "Ruang Mawar" → ruang_mawar', + style: TextStyle(fontSize: 12, color: Colors.grey[600]), + ), + ], + ); + } + + Widget _buildDeviceIdField() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildLabel('Device ID', Icons.devices), + const SizedBox(height: 8), + _buildTextField( + controller: _deviceIdController, + hint: 'Contoh: INF-ICU-001', + icon: Icons.router_outlined, + validator: (value) { + if (value?.isEmpty ?? true) return 'Wajib diisi'; + if (!RegExp(r'^[A-Z]+-[A-Z]+-\d{3}$').hasMatch(value!)) { + return 'Format: INF-ROOM-XXX'; + } + return null; + }, + ), + const SizedBox(height: 4), + Text( + 'Format: INF-ROOM-XXX', + style: TextStyle(fontSize: 12, color: Colors.grey[600]), + ), + ], + ); + } + + Widget _buildFooter() { + return Container( + padding: const EdgeInsets.all(24), + decoration: BoxDecoration( + color: Colors.grey[50], + borderRadius: const BorderRadius.only( + bottomLeft: Radius.circular(24), + bottomRight: Radius.circular(24), + ), + ), + child: Row( + children: [ + Expanded(child: _buildCancelButton()), + const SizedBox(width: 12), + Expanded(child: _buildSaveButton()), + ], + ), + ); + } + + Widget _buildCancelButton() { + return OutlinedButton( + onPressed: _isSaving ? null : () => Get.back(), + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 16), + side: BorderSide(color: Colors.grey.shade300, width: 2), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + ), + child: Text( + 'Batal', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: _isSaving ? Colors.grey[400] : Colors.grey[700], + ), + ), + ); + } + + Widget _buildSaveButton() { + return Container( + decoration: BoxDecoration( + gradient: const LinearGradient( + colors: [Color(0xFF0091EA), Color(0xFF0277BD)], + ), + borderRadius: BorderRadius.circular(12), + boxShadow: [ + BoxShadow( + color: const Color(0xFF0091EA).withOpacity(0.4), + blurRadius: 8, + offset: const Offset(0, 4), + ), + ], + ), + child: ElevatedButton( + onPressed: _isSaving ? null : _handleSave, + style: ElevatedButton.styleFrom( + backgroundColor: Colors.transparent, + shadowColor: Colors.transparent, + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + ), + child: _isSaving + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2), + ) + : Text( + widget.isEdit ? 'Simpan' : 'Tambah', + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: Colors.white, + ), + ), + ), + ); + } + Widget _buildLabel(String text, IconData icon) { return Row( children: [ @@ -295,15 +410,23 @@ class _ModalPatientAddEditState extends State { required String hint, required IconData icon, String? Function(String?)? validator, + bool obscureText = false, + Widget? suffixIcon, + TextInputType? keyboardType, + bool enabled = true, }) { return TextFormField( controller: controller, validator: validator, + enabled: !_isSaving && enabled, + obscureText: obscureText, + keyboardType: keyboardType, decoration: InputDecoration( hintText: hint, hintStyle: TextStyle(color: Colors.grey[400], fontSize: 14), filled: true, - fillColor: Colors.grey[50], + fillColor: enabled ? Colors.grey[50] : Colors.grey[200], + suffixIcon: suffixIcon, border: OutlineInputBorder( borderRadius: BorderRadius.circular(12), borderSide: BorderSide(color: Colors.grey.shade200), @@ -312,6 +435,10 @@ class _ModalPatientAddEditState extends State { borderRadius: BorderRadius.circular(12), borderSide: BorderSide(color: Colors.grey.shade200), ), + disabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: Colors.grey.shade300), + ), focusedBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(12), borderSide: const BorderSide(color: Color(0xFF0091EA), width: 2), @@ -329,58 +456,38 @@ class _ModalPatientAddEditState extends State { ); } - void _handleSave() { - if (_formKey.currentState!.validate()) { - final controller = Get.find(); + Future _handleSave() async { + if (!_formKey.currentState!.validate()) return; + setState(() => _isSaving = true); + + final controller = Get.find(); + + try { if (widget.isEdit && widget.patient != null) { - // Update existing patient - controller.updatePatient( - widget.patient!.id, - Patient( - id: widget.patient!.id, - name: _nameController.text.trim(), - room: _roomController.text.trim(), - deviceId: _deviceIdController.text.trim(), - guardian: _guardianController.text.trim(), - ), - ); - - Get.back(); - Get.snackbar( - 'Berhasil', - 'Data pasien berhasil diperbarui', - snackPosition: SnackPosition.BOTTOM, - backgroundColor: const Color(0xFF4CAF50), - colorText: Colors.white, - margin: const EdgeInsets.all(16), - icon: const Icon(Icons.check_circle, color: Colors.white), - duration: const Duration(seconds: 2), + await controller.updatePatient( + patientId: widget.patient!.id, + patientName: _nameController.text.trim(), + guardianName: _guardianController.text.trim(), + roomName: _roomController.text.trim(), + deviceId: _deviceIdController.text.trim(), + email: _emailController.text.trim(), + newPassword: _passwordController.text.isNotEmpty + ? _passwordController.text.trim() + : null, ); } else { - // Add new patient - controller.addPatient( - Patient( - id: DateTime.now().millisecondsSinceEpoch.toString(), - name: _nameController.text.trim(), - room: _roomController.text.trim(), - deviceId: _deviceIdController.text.trim(), - guardian: _guardianController.text.trim(), - ), - ); - - Get.back(); - Get.snackbar( - 'Berhasil', - 'Pasien baru berhasil ditambahkan', - snackPosition: SnackPosition.BOTTOM, - backgroundColor: const Color(0xFF4CAF50), - colorText: Colors.white, - margin: const EdgeInsets.all(16), - icon: const Icon(Icons.check_circle, color: Colors.white), - duration: const Duration(seconds: 2), + await controller.addPatient( + patientName: _nameController.text.trim(), + guardianName: _guardianController.text.trim(), + roomName: _roomController.text.trim(), + deviceId: _deviceIdController.text.trim(), + email: _emailController.text.trim(), + password: _passwordController.text.trim(), ); } + } catch (e) { + setState(() => _isSaving = false); } } } \ No newline at end of file diff --git a/lib/app/widgets/modal_schedule_add_edit.dart b/lib/app/widgets/modal_schedule_add_edit.dart index f11a4c3..9078a36 100644 --- a/lib/app/widgets/modal_schedule_add_edit.dart +++ b/lib/app/widgets/modal_schedule_add_edit.dart @@ -1,6 +1,6 @@ -// lib/app/widgets/modal_schedule_add_edit.dart import 'package:flutter/material.dart'; import 'package:get/get.dart'; +import 'package:intl/intl.dart'; import '../modules/schedule/controllers/schedule_controller.dart'; class ModalScheduleAddEdit extends StatefulWidget { @@ -16,55 +16,49 @@ class ModalScheduleAddEdit extends StatefulWidget { class _ModalScheduleAddEditState extends State { final _formKey = GlobalKey(); - String? _selectedPatient; - String? _selectedTime; // Pagi, Siang, Sore, Malam + String? _selectedPatientId; + String? _selectedTime; + DateTime? _selectedDate; late TextEditingController _medicineController; late TextEditingController _fluidController; - // Dummy data pasien - final List> _patients = [ - { - 'id': '1', - 'name': 'Mrs. Shinta', - 'room': 'Ruang Mawar 002', - 'device': 'SI001', - }, - { - 'id': '2', - 'name': 'Mr. Parjo', - 'room': 'Ruang Melati 001', - 'device': 'SI002', - }, - { - 'id': '3', - 'name': 'Mrs. Siti', - 'room': 'Ruang Anggrek 003', - 'device': 'SI003', - }, - { - 'id': '4', - 'name': 'Mr. Budi', - 'room': 'Ruang Dahlia 004', - 'device': 'SI004', - }, - ]; + List> _patients = []; + bool _isLoadingPatients = true; - // Waktu final List _times = ['Pagi', 'Siang', 'Sore', 'Malam']; @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) { - _selectedPatient = widget.schedule!.id; - _selectedTime = 'Pagi'; // Default atau bisa diambil dari schedule + _selectedPatientId = widget.schedule!.id; + _selectedTime = widget.schedule!.timeOfDay; + } + + _loadPatients(); + } + + Future _loadPatients() async { + try { + final controller = Get.find(); + final patientsList = await controller.getPatientsList(); + + if (mounted) { + setState(() { + _patients = patientsList; + _isLoadingPatients = false; + }); + } + } catch (e) { + if (mounted) { + setState(() { + _isLoadingPatients = false; + }); + } } } @@ -76,8 +70,8 @@ class _ModalScheduleAddEditState extends State { } Map? get _selectedPatientData { - if (_selectedPatient == null) return null; - return _patients.firstWhere((p) => p['id'] == _selectedPatient); + if (_selectedPatientId == null) return null; + return _patients.firstWhereOrNull((p) => p['id'] == _selectedPatientId); } @override @@ -101,194 +95,116 @@ class _ModalScheduleAddEditState extends State { child: Column( mainAxisSize: MainAxisSize.min, children: [ - // Header - Container( - padding: const EdgeInsets.all(24), - decoration: BoxDecoration( - gradient: const LinearGradient( - colors: [Color(0xFF0091EA), Color(0xFF0277BD)], - ), - borderRadius: const BorderRadius.only( - topLeft: Radius.circular(24), - topRight: Radius.circular(24), - ), - ), - child: Row( - children: [ - Container( - padding: const EdgeInsets.all(10), - decoration: BoxDecoration( - color: Colors.white.withOpacity(0.2), - borderRadius: BorderRadius.circular(12), - ), - child: const Icon( - Icons.calendar_today, - color: Colors.white, - size: 24, - ), - ), - const SizedBox(width: 16), - Expanded( - child: Text( - widget.isEdit ? 'Edit Jadwal' : 'Tambah Jadwal Baru', - style: const TextStyle( - fontSize: 20, - fontWeight: FontWeight.bold, - color: Colors.white, - ), - ), - ), - IconButton( - onPressed: () => Get.back(), - icon: const Icon(Icons.close, color: Colors.white), - splashRadius: 20, - ), - ], - ), - ), - - // Form Content + _buildHeader(), Flexible( - child: SingleChildScrollView( - padding: const EdgeInsets.all(24), - child: Form( - key: _formKey, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // 1. Pilih Pasien (Search Dropdown) - _buildLabel('Pilih Pasien', Icons.person), - const SizedBox(height: 8), - _buildPatientDropdown(), - - // Info Pasien Terpilih - if (_selectedPatientData != null) ...[ - const SizedBox(height: 12), - _buildSelectedPatientInfo(), - ], - - const SizedBox(height: 20), - - // 2. Pilih Waktu (Pagi, Siang, Sore, Malam) - _buildLabel('Pilih Waktu', Icons.access_time), - const SizedBox(height: 8), - _buildTimeDropdown(), - const SizedBox(height: 20), - - // 3. Informasi Obat - _buildLabel('Informasi Obat', Icons.medication), - const SizedBox(height: 8), - _buildTextField( - controller: _medicineController, - hint: 'Contoh: Amoxicillin 3 ml', - icon: Icons.medical_services_outlined, - validator: (value) { - if (value == null || value.isEmpty) { - return 'Informasi obat harus diisi'; - } - return null; - }, + child: _isLoadingPatients + ? const Center( + child: Padding( + padding: EdgeInsets.all(40), + child: CircularProgressIndicator(color: Color(0xFF0091EA)), ), - const SizedBox(height: 20), - - // 4. Detail Cairan & Tetes - _buildLabel('Detail Cairan & Tetes', Icons.water_drop), - const SizedBox(height: 8), - _buildTextField( - controller: _fluidController, - hint: 'Contoh: Mnt 30 tetes/jam', - icon: Icons.opacity, - validator: (value) { - if (value == null || value.isEmpty) { - return 'Detail cairan harus diisi'; - } - return null; - }, - ), - ], - ), - ), - ), - ), - - // Bottom Buttons - Container( - padding: const EdgeInsets.all(24), - decoration: BoxDecoration( - color: Colors.grey[50], - borderRadius: const BorderRadius.only( - bottomLeft: Radius.circular(24), - bottomRight: Radius.circular(24), - ), - ), - child: Row( - children: [ - Expanded( - child: OutlinedButton( - onPressed: () => Get.back(), - style: OutlinedButton.styleFrom( - padding: const EdgeInsets.symmetric(vertical: 16), - side: BorderSide(color: Colors.grey.shade300, width: 2), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - ), - child: Text( - 'Batal', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w600, - color: Colors.grey[700], + ) + : SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildLabel('Pilih Tanggal', Icons.calendar_today), + const SizedBox(height: 8), + _buildDatePicker(context), + const SizedBox(height: 20), + _buildLabel('Pilih Pasien', Icons.person), + const SizedBox(height: 8), + _buildPatientDropdown(), + if (_selectedPatientData != null) ...[ + const SizedBox(height: 12), + _buildSelectedPatientInfo(), + ], + const SizedBox(height: 20), + _buildLabel('Pilih Waktu', Icons.access_time), + const SizedBox(height: 8), + _buildTimeDropdown(), + const SizedBox(height: 20), + _buildLabel('Informasi Obat', Icons.medication), + const SizedBox(height: 8), + _buildTextField( + controller: _medicineController, + hint: 'Contoh: Amoxicillin 3 ml', + validator: (value) { + if (value == null || value.isEmpty) { + return 'Informasi obat harus diisi'; + } + return null; + }, + ), + const SizedBox(height: 20), + _buildLabel('Detail Cairan & Tetes', Icons.water_drop), + const SizedBox(height: 8), + _buildTextField( + controller: _fluidController, + hint: 'Contoh: Mnt 30 tetes/jam', + validator: (value) { + if (value == null || value.isEmpty) { + return 'Detail cairan harus diisi'; + } + return null; + }, + ), + ], ), ), ), - ), - const SizedBox(width: 12), - Expanded( - child: Container( - decoration: BoxDecoration( - gradient: const LinearGradient( - colors: [Color(0xFF0091EA), Color(0xFF0277BD)], - ), - borderRadius: BorderRadius.circular(12), - boxShadow: [ - BoxShadow( - color: const Color(0xFF0091EA).withOpacity(0.4), - blurRadius: 8, - offset: const Offset(0, 4), - ), - ], - ), - child: ElevatedButton( - onPressed: _handleSave, - style: ElevatedButton.styleFrom( - backgroundColor: Colors.transparent, - shadowColor: Colors.transparent, - padding: const EdgeInsets.symmetric(vertical: 16), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - ), - child: Text( - widget.isEdit ? 'Simpan' : 'Tambah', - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.w600, - color: Colors.white, - ), - ), - ), - ), - ), - ], - ), ), + _buildBottomButtons(), ], ), ), ); } + Widget _buildHeader() { + return Container( + padding: const EdgeInsets.all(24), + decoration: const BoxDecoration( + gradient: LinearGradient(colors: [Color(0xFF0091EA), Color(0xFF0277BD)]), + borderRadius: BorderRadius.only( + topLeft: Radius.circular(24), + topRight: Radius.circular(24), + ), + ), + child: Row( + children: [ + Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.2), + borderRadius: BorderRadius.circular(12), + ), + child: const Icon(Icons.calendar_today, color: Colors.white, size: 24), + ), + const SizedBox(width: 16), + Expanded( + child: Text( + widget.isEdit ? 'Edit Jadwal' : 'Tambah Jadwal Baru', + style: const TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + color: Colors.white, + ), + ), + ), + IconButton( + onPressed: () => Get.back(), + icon: const Icon(Icons.close, color: Colors.white), + splashRadius: 20, + ), + ], + ), + ); + } + Widget _buildLabel(String text, IconData icon) { return Row( children: [ @@ -306,9 +222,80 @@ class _ModalScheduleAddEditState extends State { ); } + Widget _buildDatePicker(BuildContext context) { + return GestureDetector( + onTap: () async { + final DateTime? picked = await showDatePicker( + context: context, + initialDate: _selectedDate ?? DateTime.now(), + firstDate: DateTime(2020), + lastDate: DateTime(2030), + builder: (context, child) { + return Theme( + data: Theme.of(context).copyWith( + colorScheme: const ColorScheme.light( + primary: Color(0xFF0091EA), + onPrimary: Colors.white, + onSurface: Color(0xFF424242), + ), + ), + child: child!, + ); + }, + ); + if (picked != null) { + setState(() { + _selectedDate = picked; + }); + } + }, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + decoration: BoxDecoration( + color: Colors.grey[50], + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.grey.shade200), + ), + child: Row( + children: [ + 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!) + : 'Pilih tanggal...', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: _selectedDate != null ? const Color(0xFF424242) : Colors.grey[400], + ), + ), + const Spacer(), + Icon(Icons.arrow_drop_down, color: Colors.grey[600]), + ], + ), + ), + ); + } + Widget _buildPatientDropdown() { + if (_patients.isEmpty) { + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.grey[100], + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.grey.shade200), + ), + child: Text( + 'Tidak ada pasien tersedia', + style: TextStyle(color: Colors.grey[600]), + ), + ); + } + return DropdownButtonFormField( - value: _selectedPatient, + value: _selectedPatientId, decoration: InputDecoration( hintText: 'Cari dan pilih pasien...', hintStyle: TextStyle(color: Colors.grey[400], fontSize: 14), @@ -330,10 +317,7 @@ class _ModalScheduleAddEditState extends State { 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, @@ -352,7 +336,7 @@ class _ModalScheduleAddEditState extends State { }).toList(), onChanged: (value) { setState(() { - _selectedPatient = value; + _selectedPatientId = value; }); }, validator: (value) { @@ -364,117 +348,99 @@ class _ModalScheduleAddEditState extends State { ); } -Widget _buildSelectedPatientInfo() { - final patient = _selectedPatientData!; + Widget _buildSelectedPatientInfo() { + final patient = _selectedPatientData!; - return Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: const Color(0xFF0091EA).withOpacity(0.05), - borderRadius: BorderRadius.circular(12), - border: Border.all( - color: const Color(0xFF0091EA).withOpacity(0.2), - width: 1, - ), - ), - child: Row( - children: [ - Container( - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - color: const Color(0xFF0091EA).withOpacity(0.1), - borderRadius: BorderRadius.circular(8), - ), - child: const Icon( - Icons.person, - color: Color(0xFF0091EA), - size: 20, - ), + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: const Color(0xFF0091EA).withOpacity(0.05), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: const Color(0xFF0091EA).withOpacity(0.2), + width: 1, ), - const SizedBox(width: 12), - - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // ===== NAMA ===== - Text( - patient['name']!, - style: const TextStyle( - fontSize: 15, - fontWeight: FontWeight.bold, - color: Color(0xFF424242), + ), + child: Row( + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: const Color(0xFF0091EA).withOpacity(0.1), + borderRadius: BorderRadius.circular(8), + ), + child: const Icon(Icons.person, color: Color(0xFF0091EA), size: 20), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + patient['name']!, + style: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.bold, + color: Color(0xFF424242), + ), ), - ), - const SizedBox(height: 6), - - // ===== ROOM + DEVICE ID ===== - Row( - children: [ - // ROOM - Container( - padding: const EdgeInsets.symmetric( - horizontal: 10, - vertical: 4, - ), - decoration: BoxDecoration( - color: const Color(0xFFE3F2FD), - borderRadius: BorderRadius.circular(6), - ), - child: Text( - patient['room']!, - style: const TextStyle( - fontSize: 11, - color: Color(0xFF2196F3), - fontWeight: FontWeight.w600, + const SizedBox(height: 6), + Row( + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: const Color(0xFFE3F2FD), + borderRadius: BorderRadius.circular(6), + ), + child: Text( + patient['room']!, + style: const TextStyle( + fontSize: 11, + color: Color(0xFF2196F3), + fontWeight: FontWeight.w600, + ), ), ), - ), - - const SizedBox(width: 8), - - // DEVICE ID - Container( - padding: const EdgeInsets.symmetric( - horizontal: 10, - vertical: 4, - ), - decoration: BoxDecoration( - color: Colors.grey[100], - borderRadius: BorderRadius.circular(6), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Container( - width: 6, - height: 6, - decoration: const BoxDecoration( - color: Color(0xFF4CAF50), - shape: BoxShape.circle, + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: Colors.grey[100], + borderRadius: BorderRadius.circular(6), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 6, + height: 6, + decoration: const BoxDecoration( + color: Color(0xFF4CAF50), + shape: BoxShape.circle, + ), ), - ), - const SizedBox(width: 6), - Text( - patient['device']!, - style: TextStyle( - fontSize: 11, - color: Colors.grey[700], - fontWeight: FontWeight.w600, + const SizedBox(width: 6), + Text( + patient['device']!, + style: TextStyle( + fontSize: 11, + color: Colors.grey[700], + fontWeight: FontWeight.w600, + ), ), - ), - ], + ], + ), ), - ), - ], - ), - ], + ], + ), + ], + ), ), - ), - ], - ), - ); -} + ], + ), + ); + } Widget _buildTimeDropdown() { return DropdownButtonFormField( @@ -500,10 +466,7 @@ Widget _buildSelectedPatientInfo() { 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, @@ -568,7 +531,6 @@ Widget _buildSelectedPatientInfo() { Widget _buildTextField({ required TextEditingController controller, required String hint, - required IconData icon, String? Function(String?)? validator, }) { return TextFormField( @@ -599,73 +561,112 @@ Widget _buildSelectedPatientInfo() { 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), + ), + ); + } + + Widget _buildBottomButtons() { + return Container( + padding: const EdgeInsets.all(24), + decoration: BoxDecoration( + color: Colors.grey[50], + borderRadius: const BorderRadius.only( + bottomLeft: Radius.circular(24), + bottomRight: Radius.circular(24), ), ), + child: Row( + children: [ + Expanded( + child: OutlinedButton( + onPressed: () => Get.back(), + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 16), + side: BorderSide(color: Colors.grey.shade300, width: 2), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + ), + child: Text( + 'Batal', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: Colors.grey[700], + ), + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Container( + decoration: BoxDecoration( + gradient: const LinearGradient( + colors: [Color(0xFF0091EA), Color(0xFF0277BD)], + ), + borderRadius: BorderRadius.circular(12), + boxShadow: [ + BoxShadow( + color: const Color(0xFF0091EA).withOpacity(0.4), + blurRadius: 8, + offset: const Offset(0, 4), + ), + ], + ), + child: ElevatedButton( + onPressed: _handleSave, + style: ElevatedButton.styleFrom( + backgroundColor: Colors.transparent, + shadowColor: Colors.transparent, + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + ), + child: Text( + widget.isEdit ? 'Simpan' : 'Tambah', + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: Colors.white, + ), + ), + ), + ), + ), + ], + ), ); } void _handleSave() { if (_formKey.currentState!.validate()) { + if (_selectedDate == null) { + Get.snackbar( + 'Error', + 'Pilih tanggal terlebih dahulu', + backgroundColor: Colors.redAccent, + colorText: Colors.white, + ); + return; + } + final controller = Get.find(); - final selectedPatientData = _patients.firstWhere( - (p) => p['id'] == _selectedPatient, + + final schedule = PatientSchedule( + id: _selectedPatientId!, + name: _selectedPatientData!['name']!, + room: _selectedPatientData!['room']!, + deviceId: _selectedPatientData!['device']!, + medicineTime: _medicineController.text.trim(), + fluidTime: _fluidController.text.trim(), + timeOfDay: _selectedTime!, ); if (widget.isEdit && widget.schedule != null) { - controller.updateSchedule( - widget.schedule!.id, - PatientSchedule( - id: widget.schedule!.id, - name: selectedPatientData['name']!, - room: selectedPatientData['room']!, - deviceId: selectedPatientData['device']!, - medicineType: 'Obat & Dosis', - fluidType: 'Cairan & Tetes', - medicineTime: _medicineController.text.trim(), - fluidTime: _fluidController.text.trim(), - ), - ); - - Get.back(); - Get.snackbar( - 'Berhasil', - 'Jadwal $_selectedTime berhasil diperbarui', - snackPosition: SnackPosition.BOTTOM, - backgroundColor: const Color(0xFF4CAF50), - colorText: Colors.white, - margin: const EdgeInsets.all(16), - icon: const Icon(Icons.check_circle, color: Colors.white), - duration: const Duration(seconds: 2), - ); + controller.updateSchedule(widget.schedule!.id, schedule, _selectedDate!); } else { - controller.addSchedule( - PatientSchedule( - id: DateTime.now().millisecondsSinceEpoch.toString(), - name: selectedPatientData['name']!, - room: selectedPatientData['room']!, - deviceId: selectedPatientData['device']!, - medicineType: 'Obat & Dosis', - fluidType: 'Cairan & Tetes', - medicineTime: _medicineController.text.trim(), - fluidTime: _fluidController.text.trim(), - ), - ); - - Get.back(); - Get.snackbar( - 'Berhasil', - 'Jadwal $_selectedTime berhasil ditambahkan', - snackPosition: SnackPosition.BOTTOM, - backgroundColor: const Color(0xFF4CAF50), - colorText: Colors.white, - margin: const EdgeInsets.all(16), - icon: const Icon(Icons.check_circle, color: Colors.white), - duration: const Duration(seconds: 2), - ); + controller.addSchedule(schedule, _selectedDate!); } + + Get.back(); } } -} +} \ No newline at end of file diff --git a/lib/app/widgets/monitoring_card.dart b/lib/app/widgets/monitoring_card.dart index 8201afe..0dd9e62 100644 --- a/lib/app/widgets/monitoring_card.dart +++ b/lib/app/widgets/monitoring_card.dart @@ -1,16 +1,20 @@ -// lib/app/widgets/monitoring_card.dart import 'package:flutter/material.dart'; -import 'package:get/get.dart'; import '../modules/home/controllers/home_controller.dart'; class MonitoringCard extends StatelessWidget { - final InfusMonitoring item; + final InfusMonitoring monitoring; final VoidCallback? onTap; - const MonitoringCard({super.key, required this.item, this.onTap}); + const MonitoringCard({ + super.key, + required this.monitoring, + this.onTap, + }); @override Widget build(BuildContext context) { + final isConnected = monitoring.deviceStatus == 'connected'; + return InkWell( onTap: onTap, borderRadius: BorderRadius.circular(16), @@ -20,7 +24,7 @@ class MonitoringCard extends StatelessWidget { decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(16), - border: Border.all(color: Colors.grey.shade200, width: 1.5), + border: Border.all(color: Colors.grey[200]!), boxShadow: [ BoxShadow( color: Colors.grey.withOpacity(0.08), @@ -30,155 +34,166 @@ class MonitoringCard extends StatelessWidget { ], ), child: Row( - crossAxisAlignment: CrossAxisAlignment.start, children: [ - // LEFT ICON - Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: const Color(0xFFE3F2FD), - borderRadius: BorderRadius.circular(14), - ), - child: const Icon( - Icons.water_drop_outlined, - size: 36, - color: Color(0xFF2196F3), - ), - ), + _buildIcon(isConnected), const SizedBox(width: 16), - - // RIGHT CONTENT - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // DROPS - Row( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Text( - item.dropsPerMinute.split(' ')[0], - style: const TextStyle( - fontSize: 24, - fontWeight: FontWeight.bold, - color: Color(0xFF2196F3), - height: 1, - ), - ), - const SizedBox(width: 6), - Padding( - padding: const EdgeInsets.only(bottom: 3), - child: Text( - 'tetes/menit', - style: TextStyle( - fontSize: 13, - color: Colors.grey.shade600, - fontWeight: FontWeight.w600, - ), - ), - ), - ], - ), - - const SizedBox(height: 6), - - // LAST UPDATE - Text( - 'Last update • ${Get.find().getLastUpdateText(item.lastUpdate)}', - style: TextStyle(fontSize: 11, color: Colors.grey.shade600), - ), - - const SizedBox(height: 12), - - // ROOM & DEVICE - Wrap( - spacing: 8, - children: [ - // ROOM BADGE - Container( - padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 6, - ), - decoration: BoxDecoration( - color: const Color(0xFFE3F2FD), - borderRadius: BorderRadius.circular(8), - ), - child: Text( - item.room, - style: const TextStyle( - fontSize: 11, - fontWeight: FontWeight.w600, - color: Color(0xFF2196F3), - ), - ), - ), - - // DEVICE BADGE - Container( - padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 6, - ), - decoration: BoxDecoration( - color: Colors.grey.shade100, - borderRadius: BorderRadius.circular(8), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Container( - width: 6, - height: 6, - decoration: const BoxDecoration( - color: Color(0xFF4CAF50), - shape: BoxShape.circle, - ), - ), - const SizedBox(width: 6), - Text( - item.deviceId, - style: TextStyle( - fontSize: 11, - fontWeight: FontWeight.w600, - color: Colors.grey.shade700, - ), - ), - ], - ), - ), - ], - ), - - const SizedBox(height: 8), - - // PATIENT NAME BADGE (AUTO WIDTH - GREEN THEME) - Container( - padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 6, - ), - decoration: BoxDecoration( - color: const Color(0xFFE8F5E9), // hijau soft - borderRadius: BorderRadius.circular(8), - ), - child: Text( - item.patientName, - style: const TextStyle( - fontSize: 11, - fontWeight: FontWeight.w600, - color: Color(0xFF2E7D32), - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ), - ], - ), - ), + Expanded(child: _buildContent(isConnected)), ], ), ), ); } -} + + Widget _buildIcon(bool isConnected) { + return Stack( + children: [ + Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: const Color(0xFFE3F2FD), + borderRadius: BorderRadius.circular(14), + ), + child: const Icon( + Icons.water_drop_outlined, + size: 32, + color: Color(0xFF2196F3), + ), + ), + Positioned( + right: 0, + top: 0, + child: Container( + width: 12, + height: 12, + decoration: BoxDecoration( + color: isConnected ? const Color(0xFF4CAF50) : Colors.red, + shape: BoxShape.circle, + border: Border.all(color: Colors.white, width: 2), + ), + ), + ), + ], + ); + } + + Widget _buildContent(bool isConnected) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildDropRate(), + const SizedBox(height: 6), + _buildLastUpdate(), + const SizedBox(height: 12), + _buildPatientInfo(), + const SizedBox(height: 8), + _buildBadges(isConnected), + ], + ); + } + + Widget _buildDropRate() { + return Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + monitoring.dropsPerMinute.replaceAll(' tpm', ''), + style: const TextStyle( + fontSize: 22, + fontWeight: FontWeight.bold, + color: Color(0xFF2196F3), + height: 1, + ), + ), + const SizedBox(width: 6), + const Padding( + padding: EdgeInsets.only(bottom: 2), + child: Text( + 'tetes per menit', + style: TextStyle( + fontSize: 12, + color: Colors.black54, + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ); + } + + Widget _buildLastUpdate() { + return Text( + 'Last update: ${_getLastUpdateText(monitoring.lastUpdate)}', + style: TextStyle(fontSize: 11, color: Colors.grey[600]), + ); + } + + Widget _buildPatientInfo() { + return RichText( + text: TextSpan( + style: TextStyle(fontSize: 12, color: Colors.grey[700]), + children: [ + const TextSpan( + text: 'Nama Pasien: ', + style: TextStyle(fontWeight: FontWeight.w500), + ), + TextSpan( + text: monitoring.patientName, + style: const TextStyle( + fontWeight: FontWeight.w600, + color: Colors.black87, + ), + ), + ], + ), + ); + } + + Widget _buildBadges(bool isConnected) { + return Wrap( + spacing: 8, + runSpacing: 6, + children: [ + _buildBadge( + monitoring.deviceId, + const Color(0xFFE3F2FD), + const Color(0xFF2196F3), + ), + _buildBadge( + monitoring.room, + Colors.grey[100]!, + Colors.grey[700]!, + ), + _buildBadge( + isConnected ? 'Online' : 'Offline', + isConnected ? const Color(0xFFE8F5E9) : Colors.red[50]!, + isConnected ? const Color(0xFF2E7D32) : Colors.red[700]!, + ), + ], + ); + } + + Widget _buildBadge(String text, Color bgColor, Color textColor) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), + decoration: BoxDecoration( + color: bgColor, + borderRadius: BorderRadius.circular(6), + ), + child: Text( + text, + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: textColor, + ), + ), + ); + } + + String _getLastUpdateText(DateTime lastUpdate) { + final diff = DateTime.now().difference(lastUpdate); + if (diff.inMinutes < 60) return '${diff.inMinutes}m ago'; + if (diff.inHours < 24) return '${diff.inHours}h ago'; + return '${diff.inDays}d ago'; + } +} \ No newline at end of file diff --git a/lib/app/widgets/schedule_card.dart b/lib/app/widgets/schedule_card.dart index 88a0b4a..f3f3de6 100644 --- a/lib/app/widgets/schedule_card.dart +++ b/lib/app/widgets/schedule_card.dart @@ -1,4 +1,3 @@ -// lib/app/widgets/schedule_card.dart import 'package:flutter/material.dart'; import '../modules/schedule/controllers/schedule_controller.dart'; @@ -34,238 +33,203 @@ class ScheduleCard extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - // ================= HEADER ================= - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, + _buildHeader(), + const SizedBox(height: 12), + Container(height: 1, color: Colors.grey.shade200), + const SizedBox(height: 16), + _buildMedicineAndFluid(), + ], + ), + ); + } + + Widget _buildHeader() { + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - schedule.name, - style: const TextStyle( - fontWeight: FontWeight.bold, - fontSize: 16, - color: Color(0xFF424242), - ), - ), - const SizedBox(height: 6), - - Row( - children: [ - // ===== ROOM ===== - Container( - padding: const EdgeInsets.symmetric( - horizontal: 10, - vertical: 4, - ), - decoration: BoxDecoration( - color: const Color(0xFFE3F2FD), - borderRadius: BorderRadius.circular(6), - ), - child: Text( - schedule.room, - style: const TextStyle( - fontSize: 11, - color: Color(0xFF2196F3), - fontWeight: FontWeight.w600, - ), - ), - ), - - const SizedBox(width: 8), - - // ===== DEVICE ID ===== - Container( - padding: const EdgeInsets.symmetric( - horizontal: 10, - vertical: 4, - ), - decoration: BoxDecoration( - color: Colors.grey[100], - borderRadius: BorderRadius.circular(6), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Container( - width: 6, - height: 6, - decoration: const BoxDecoration( - color: Color(0xFF4CAF50), - shape: BoxShape.circle, - ), - ), - const SizedBox(width: 6), - Text( - schedule.deviceId, // ✅ pastikan ada di model - style: TextStyle( - fontSize: 11, - color: Colors.grey[700], - fontWeight: FontWeight.w600, - ), - ), - ], - ), - ), - ], - ), - ], + Text( + schedule.name, + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 16, + color: Color(0xFF424242), ), ), - - /// ACTION BUTTONS + const SizedBox(height: 6), Row( children: [ - // ===== EDIT ===== - InkWell( - onTap: onEditTap, - borderRadius: BorderRadius.circular(8), - child: Container( - padding: const EdgeInsets.all(6), - decoration: BoxDecoration( - color: Colors.amber.withOpacity(0.15), - borderRadius: BorderRadius.circular(8), - ), - child: const Icon( - Icons.edit, - size: 18, - color: Colors.amber, + Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: const Color(0xFFE3F2FD), + borderRadius: BorderRadius.circular(6), + ), + child: Text( + schedule.room, + style: const TextStyle( + fontSize: 11, + color: Color(0xFF2196F3), + fontWeight: FontWeight.w600, ), ), ), const SizedBox(width: 8), - - // ===== DELETE ===== - InkWell( - onTap: onDeleteTap, - borderRadius: BorderRadius.circular(8), - child: Container( - padding: const EdgeInsets.all(6), - decoration: BoxDecoration( - color: Colors.redAccent.withOpacity(0.15), - borderRadius: BorderRadius.circular(8), - ), - child: const Icon( - Icons.delete, - size: 18, - color: Colors.redAccent, - ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: Colors.grey[100], + borderRadius: BorderRadius.circular(6), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 6, + height: 6, + decoration: const BoxDecoration( + color: Color(0xFF4CAF50), + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 6), + Text( + schedule.deviceId, + style: TextStyle( + fontSize: 11, + color: Colors.grey[700], + fontWeight: FontWeight.w600, + ), + ), + ], ), ), ], ), ], ), - - const SizedBox(height: 12), - - // ================= DIVIDER ================= - Container(height: 1, color: Colors.grey.shade200), - - const SizedBox(height: 16), - - // ================= MEDICINE & FLUID ================= - Row( - children: [ - // -------- Medicine -------- - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Container( - padding: const EdgeInsets.all(6), - decoration: BoxDecoration( - color: const Color(0xFF2196F3).withOpacity(0.1), - borderRadius: BorderRadius.circular(6), - ), - child: const Icon( - Icons.medication, - size: 16, - color: Color(0xFF2196F3), - ), - ), - const SizedBox(width: 8), - Text( - schedule.medicineType, - style: TextStyle( - color: Colors.grey[600], - fontSize: 11, - fontWeight: FontWeight.w500, - ), - ), - ], - ), - const SizedBox(height: 8), - Padding( - padding: const EdgeInsets.only(left: 4), - child: Text( - schedule.medicineTime, - style: const TextStyle( - color: Color(0xFF424242), - fontSize: 13, - fontWeight: FontWeight.w600, - ), - ), - ), - ], + ), + Row( + children: [ + InkWell( + onTap: onEditTap, + borderRadius: BorderRadius.circular(8), + child: Container( + padding: const EdgeInsets.all(6), + decoration: BoxDecoration( + color: Colors.amber.withOpacity(0.15), + borderRadius: BorderRadius.circular(8), ), + child: const Icon(Icons.edit, size: 18, color: Colors.amber), ), + ), + const SizedBox(width: 8), + InkWell( + onTap: onDeleteTap, + borderRadius: BorderRadius.circular(8), + child: Container( + padding: const EdgeInsets.all(6), + decoration: BoxDecoration( + color: Colors.redAccent.withOpacity(0.15), + borderRadius: BorderRadius.circular(8), + ), + child: const Icon(Icons.delete, size: 18, color: Colors.redAccent), + ), + ), + ], + ), + ], + ); + } - const SizedBox(width: 16), - - // -------- Fluid -------- - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Container( - padding: const EdgeInsets.all(6), - decoration: BoxDecoration( - color: const Color(0xFF00BCD4).withOpacity(0.1), - borderRadius: BorderRadius.circular(6), - ), - child: const Icon( - Icons.water_drop, - size: 16, - color: Color(0xFF00BCD4), - ), - ), - const SizedBox(width: 8), - Text( - schedule.fluidType, - style: TextStyle( - color: Colors.grey[600], - fontSize: 11, - fontWeight: FontWeight.w500, - ), - ), - ], + Widget _buildMedicineAndFluid() { + return Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + padding: const EdgeInsets.all(6), + decoration: BoxDecoration( + color: const Color(0xFF2196F3).withOpacity(0.1), + borderRadius: BorderRadius.circular(6), ), - const SizedBox(height: 8), - Padding( - padding: const EdgeInsets.only(left: 4), - child: Text( - schedule.fluidTime, - style: const TextStyle( - color: Color(0xFF424242), - fontSize: 13, - fontWeight: FontWeight.w600, - ), - ), + child: const Icon(Icons.medication, size: 16, color: Color(0xFF2196F3)), + ), + const SizedBox(width: 8), + Text( + 'Obat & Dosis', + style: TextStyle( + color: Colors.grey[600], + fontSize: 11, + fontWeight: FontWeight.w500, ), - ], + ), + ], + ), + const SizedBox(height: 8), + Padding( + padding: const EdgeInsets.only(left: 4), + child: Text( + schedule.medicineTime, + style: const TextStyle( + color: Color(0xFF424242), + fontSize: 13, + fontWeight: FontWeight.w600, + ), ), ), ], ), - ], - ), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + padding: const EdgeInsets.all(6), + decoration: BoxDecoration( + color: const Color(0xFF00BCD4).withOpacity(0.1), + borderRadius: BorderRadius.circular(6), + ), + child: const Icon(Icons.water_drop, size: 16, color: Color(0xFF00BCD4)), + ), + const SizedBox(width: 8), + Text( + 'Cairan & Tetes', + style: TextStyle( + color: Colors.grey[600], + fontSize: 11, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + const SizedBox(height: 8), + Padding( + padding: const EdgeInsets.only(left: 4), + child: Text( + schedule.fluidTime, + style: const TextStyle( + color: Color(0xFF424242), + fontSize: 13, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + ), + ], ); } -} +} \ No newline at end of file diff --git a/lib/main.dart b/lib/main.dart index 7785e50..f41ebe4 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,34 +1,23 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; -import 'package:get_storage/get_storage.dart'; -import 'package:intl/date_symbol_data_local.dart'; import 'package:firebase_core/firebase_core.dart'; +import 'package:intl/date_symbol_data_local.dart'; +import 'package:get_storage/get_storage.dart'; import 'app/routes/app_pages.dart'; void main() async { WidgetsFlutterBinding.ensureInitialized(); - // Initialize Firebase - await Firebase.initializeApp(); - - await GetStorage.init(); - await GetStorage.init('app_data'); + await Firebase.initializeApp(); + await GetStorage.init(); + await initializeDateFormatting('id_ID', null); - await initializeDateFormatting('id_ID', null); - - runApp(const MyApp()); -} - -class MyApp extends StatelessWidget { - const MyApp({super.key}); - - @override - Widget build(BuildContext context) { - return GetMaterialApp( + runApp( + GetMaterialApp( debugShowCheckedModeBanner: false, - title: "SMART INFUSION", + title: "SMART INFUSE", initialRoute: Routes.SPLASH_SCREEN, getPages: AppPages.routes, - ); - } -} \ No newline at end of file + ), + ); +} diff --git a/pubspec.yaml b/pubspec.yaml index c080436..0a25aaf 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -46,6 +46,7 @@ dependencies: firebase_auth: ^6.1.4 firebase_database: ^12.1.2 cloud_firestore: ^6.1.2 +