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 ce5af91..999fe8c 100644 Binary files a/assets/images/logo.png and b/assets/images/logo.png differ 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 +