fitur pasien home

This commit is contained in:
er4files 2026-02-13 00:34:43 +07:00
parent b87e9b50df
commit 314bcc3b59
42 changed files with 5896 additions and 2031 deletions

64
android/app/build.gradle Normal file
View File

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

View File

@ -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 = "../.."
}

View File

@ -41,5 +41,15 @@
<action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/>
</intent>
<!-- Tambahan untuk WhatsApp -->
<intent>
<action android:name="android.intent.action.VIEW" />
<data android:scheme="https" />
</intent>
<intent>
<action android:name="android.intent.action.VIEW" />
<data android:scheme="http" />
</intent>
<package android:name="com.whatsapp" />
</queries>
</manifest>

35
android/build.gradle Normal file
View File

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

View File

@ -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<Delete>("clean") {
delete(rootProject.layout.buildDirectory)
}

File diff suppressed because one or more lines are too long

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 73 KiB

After

Width:  |  Height:  |  Size: 15 KiB

View File

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

View File

@ -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<String, dynamic>;
return DeviceModel(
idDevices: doc.id,
roomId: data['room_id'] ?? '',
devicesName: data['devices_name'] ?? '',
);
}
// To Firestore
Map<String, dynamic> toFirestore() {
return {
'room_id': roomId,
'devices_name': devicesName,
};
}
}

View File

@ -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<String, dynamic>;
return HistoryModel(
id: doc.id,
deviceId: data['device_id'] ?? '',
dropPerMinute: (data['drop_per_menit'] ?? 0).toDouble(),
timestamp: (data['timestamp'] as Timestamp).toDate(),
);
}
Map<String, dynamic> toFirestore() {
return {
'device_id': deviceId,
'drop_per_menit': dropPerMinute,
'timestamp': Timestamp.fromDate(timestamp),
};
}
factory HistoryModel.fromMap(String id, Map<String, dynamic> 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)';
}
}

View File

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

View File

@ -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<String, dynamic>;
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<String, dynamic> toFirestore() {
return {
'name_patient': namePatient,
'name_guardian': nameGuardian,
'device_id': deviceId,
'room_id': roomId,
if (userId != null) 'user_id': userId,
};
}
}

View File

@ -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<dynamic, dynamic> data,
) {
final controlling = data['controlling'] as Map<dynamic, dynamic>? ?? {};
final monitoring = data['monitoring'] as Map<dynamic, dynamic>? ?? {};
final settings = data['settings'] as Map<dynamic, dynamic>? ?? {};
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<String, dynamic> 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,
);
}
}

View File

@ -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<String, dynamic>;
return RoomModel(
id: doc.id,
roomName: data['room_name'] ?? '',
capacity: data['capacity'] ?? 0,
createdAt: (data['created_at'] as Timestamp).toDate(),
);
}
Map<String, dynamic> toFirestore() {
return {
'room_name': roomName,
'capacity': capacity,
'created_at': Timestamp.fromDate(createdAt),
};
}
factory RoomModel.fromMap(String id, Map<String, dynamic> 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)';
}
}

View File

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

View File

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

View File

@ -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<ChartDataPoint> chartData = <ChartDataPoint>[].obs;
StreamSubscription? _realtimeSubscription;
StreamSubscription? _historySubscription;
PatientModel? patient;
DeviceModel? device;
RealtimeMonitoringModel? realtimeData;
final count = 0.obs;
@override
void onInit() {
super.onInit();
_getArguments();
if (deviceId != null && roomId != null) {
_initializeDeviceDetail();
} else {
Get.back();
AppSnackbar.error('Device ID tidak ditemukan');
}
@override
void onReady() {
super.onReady();
}
@override
void onClose() {
_realtimeSubscription?.cancel();
_historySubscription?.cancel();
super.onClose();
}
void increment() => count.value++;
void _getArguments() {
final args = Get.arguments as Map<String, dynamic>?;
if (args != null) {
deviceId = args['deviceId'];
roomId = args['roomId'];
}
}
Future<void> _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<void> _loadPatientData() async {
patient = await _firestoreService.getPatientByDeviceId(deviceId!);
if (patient != null) {
patientName.value = patient!.namePatient;
}
}
Future<void> _loadDeviceData() async {
device = await _firestoreService.getDeviceById(deviceId!);
if (device != null) {
deviceName.value = device!.devicesName;
roomName.value = await _getRoomName(device!.roomId);
}
}
Future<String> _getRoomName(String roomId) async {
try {
final rooms = await _firestoreService.getRoomsStream().first;
final room = rooms.firstWhereOrNull(
(r) =>
r.id == roomId || r.roomName.toLowerCase() == roomId.toLowerCase(),
);
return room?.roomName ?? roomId;
} catch (e) {
return roomId;
}
}
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<HistoryModel> 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<ChartDataPoint> points = latestHistories.map((history) {
return ChartDataPoint(
hour: history.timestamp.hour,
dropsPerMinute: history.dropPerMinute,
);
}).toList();
chartData.value = points;
}
Future<void> 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<void> refreshData() async {
await _loadDeviceData();
}
}

View File

@ -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<DeviceDetailController> {
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(),
),
body: const Center(
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: 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(
'DeviceDetailView is working',
style: TextStyle(fontSize: 20),
'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,
),
),
);

View File

@ -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;
final FirestoreService _firestoreService = FirestoreService();
final RealtimeDatabaseService _realtimeService = RealtimeDatabaseService();
final deviceId = ''.obs;
final roomId = ''.obs;
final patientName = 'Patient'.obs;
final userEmail = ''.obs;
// Banner carousel
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>[
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;
final chartDataPoints = <ChartDataPoint>[].obs;
final notificationsList = <NotificationData>[].obs;
// Notifications list
final notificationsList = <NotificationData>[
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 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<String, dynamic>?;
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<void> _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<HistoryModel> 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<ChartDataPoint> 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<NotificationData> 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'),
],
),
),
],
);
},
);
}
Future<void> _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<void> refreshData() async {
try {
await _loadPatientData();
// Tidak perlu reload history karena sudah menggunakan stream
// History akan auto-update
} catch (e) {
print('Error refreshing data: $e');
AppSnackbar.error('Gagal refresh data: $e');
}
}
}

View File

@ -1,11 +1,11 @@
// 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<HomePatientController> {
const HomePatientView({super.key});
@ -17,7 +17,10 @@ class HomePatientView extends GetView<HomePatientController> {
return Scaffold(
backgroundColor: Colors.white,
body: SafeArea(
child: RefreshIndicator(
onRefresh: controller.refreshData,
child: SingleChildScrollView(
physics: const AlwaysScrollableScrollPhysics(),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@ -27,31 +30,35 @@ class HomePatientView extends GetView<HomePatientController> {
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Obx(
Expanded(
child: Obx(
() => Text(
'Halo ${controller.patientName.value}',
style: const TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
),
overflow: TextOverflow.ellipsis,
),
),
Container(
),
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,
child: const Icon(Icons.logout, color: Colors.red),
),
),
],
),
),
// Banner Slider - Gunakan widget universal
// Banner Slider
BannerSlider(
bannerImages: controller.bannerImages,
currentCarouselIndex: controller.currentCarouselIndex,
@ -74,7 +81,9 @@ class HomePatientView extends GetView<HomePatientController> {
),
),
TextButton(
onPressed: () {},
onPressed: () {
// TODO: Navigate to monitoring detail
},
child: const Text(
'See all',
style: TextStyle(
@ -87,20 +96,44 @@ class HomePatientView extends GetView<HomePatientController> {
),
),
// Monitoring Card
// Monitoring Card - Gunakan MonitoringCard dari widgets
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,
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 bookmark tap
// TODO: Navigate to detail
},
),
),
);
}),
),
const SizedBox(height: 15),
@ -119,7 +152,9 @@ class HomePatientView extends GetView<HomePatientController> {
),
),
TextButton(
onPressed: () {},
onPressed: () {
// TODO: Navigate to history detail
},
child: const Text(
'See all',
style: TextStyle(
@ -132,17 +167,29 @@ class HomePatientView extends GetView<HomePatientController> {
),
),
// Chart Card
// Chart Card - Gunakan HistoryGrafikCard dari widgets
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Obx(
() => HistoryGrafikCard(
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: () {
// Handle bookmark tap
// TODO: Bookmark functionality
},
),
),
);
}),
),
const SizedBox(height: 15),
@ -161,7 +208,9 @@ class HomePatientView extends GetView<HomePatientController> {
),
),
TextButton(
onPressed: () {},
onPressed: () {
// TODO: Navigate to notifications
},
child: const Text(
'See all',
style: TextStyle(
@ -175,8 +224,48 @@ class HomePatientView extends GetView<HomePatientController> {
),
// Notifications List
Obx(
() => ListView.builder(
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),
@ -190,27 +279,25 @@ class HomePatientView extends GetView<HomePatientController> {
deviceId: notification.deviceId,
timeAgo: notification.timeAgo,
onTap: () {
// Handle notification tap
// 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),
),
);
}

View File

@ -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>[
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 = <InfusMonitoring>[].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<PatientModel> patients,
List<RealtimeMonitoringModel> realtimeDevices,
) async {
final List<InfusMonitoring> 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<String> _getRoomName(String roomId) async {
try {
final rooms = await _firestoreService.getRoomsStream().first;
final room = rooms.firstWhereOrNull(
(r) =>
r.id == roomId || r.roomName.toLowerCase() == roomId.toLowerCase(),
);
return room?.roomName ?? roomId;
} catch (e) {
return roomId;
}
}
Future<void> 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 {

View File

@ -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';
@ -18,18 +17,30 @@ class HomeView extends GetView<HomeController> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header
Padding(
_buildHeader(context),
BannerSlider(
bannerImages: controller.bannerImages,
currentCarouselIndex: controller.currentCarouselIndex,
onPageChanged: controller.updateCarouselIndex,
),
const SizedBox(height: 15),
_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,
),
style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold),
),
InkWell(
onTap: () => controller.showLogoutDialog(context),
@ -44,62 +55,80 @@ class HomeView extends GetView<HomeController> {
),
],
),
),
);
}
// Banner Slider - Gunakan widget universal
BannerSlider(
bannerImages: controller.bannerImages,
currentCarouselIndex: controller.currentCarouselIndex,
onPageChanged: controller.updateCarouselIndex,
),
const SizedBox(height: 15),
// Monitoring Infus Section
Padding(
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,
),
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
TextButton(
onPressed: () {},
onPressed: () => Get.toNamed('/all-monitoring'),
child: const Text(
'See all',
'Lihat Semua',
style: TextStyle(color: Color(0xFF0091EA)),
),
),
],
),
),
);
}
// Monitoring List
Expanded(
child: Obx(
() => ListView.builder(
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(
item: item,
onTap: () {
// Handle tap jika diperlukan
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]),
),
],
),
),
);
}
}

View File

@ -1,39 +1,172 @@
import 'package:flutter/material.dart';
import 'package:get/get.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],
);
}
}

View File

@ -14,6 +14,8 @@ class LoginView extends GetView<LoginController> {
body: SafeArea(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Center(
// Wrap the entire content inside Center widget to center vertically
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@ -23,15 +25,6 @@ class LoginView extends GetView<LoginController> {
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,
),
),
const SizedBox(height: 50),
Container(
decoration: BoxDecoration(
color: Colors.grey[100],
@ -39,8 +32,9 @@ class LoginView extends GetView<LoginController> {
),
child: TextField(
controller: controller.usernameController,
keyboardType: TextInputType.emailAddress,
decoration: InputDecoration(
hintText: 'Masukkan username',
hintText: 'Masukkan email',
hintStyle: TextStyle(color: Colors.grey[400]),
prefixIcon: Icon(
Icons.mail_outline,
@ -84,18 +78,30 @@ class LoginView extends GetView<LoginController> {
),
),
const SizedBox(height: 30),
SizedBox(
Obx(
() => SizedBox(
width: double.infinity,
height: 55,
child: ElevatedButton(
onPressed: controller.login,
onPressed: controller.isLoading.value
? null
: controller.login,
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF1976D2),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(15),
),
),
child: const Text(
child: controller.isLoading.value
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(
color: Colors.white,
strokeWidth: 2,
),
)
: const Text(
'Login',
style: TextStyle(
fontSize: 18,
@ -105,6 +111,7 @@ class LoginView extends GetView<LoginController> {
),
),
),
),
const SizedBox(height: 20),
Row(
mainAxisAlignment: MainAxisAlignment.center,
@ -129,6 +136,7 @@ class LoginView extends GetView<LoginController> {
),
),
),
),
);
}
}

View File

@ -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>[
final selectedTab = 0.obs;
final _allNotifications = <NotificationItem>[].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<NotificationItem> items = [];
for (var notifModel in notificationModels) {
// Get patient info
final patient = await _firestoreService.getPatientById(
notifModel.patientId,
);
// Get room name from patient's roomId
String roomName = 'Unknown Room';
if (patient != null && patient.roomId.isNotEmpty) {
final rooms = await _firestoreService.getRoomsStream().first;
final room = rooms.firstWhereOrNull(
(r) => r.roomName.toLowerCase() == patient.roomId.toLowerCase(),
);
roomName = room?.roomName ?? patient.roomId;
}
items.add(
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,
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,
),
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;
);
}
_allNotifications.value = items;
isLoading.value = false;
}, onError: (error) {
print('Error listening to notifications: $error');
isLoading.value = false;
});
}
// Computed property untuk notifikasi yang difilter
List<NotificationItem> get notifications {
@ -106,15 +134,35 @@ class NotificationController extends GetxController {
selectedTab.value = index;
}
void deleteNotification(String id) {
_allNotifications.removeWhere((notif) => notif.id == id);
Future<void> 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<void> 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<void> 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');
}
}
}

View File

@ -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<String, dynamic> 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<String, dynamic>;
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>[
final FirestoreService _firestoreService = FirestoreService();
final RealtimeDatabaseService _realtimeService = RealtimeDatabaseService();
final patients = <Patient>[].obs;
final isLoading = false.obs;
@override
void onInit() {
super.onInit();
_listenToPatients();
}
void refreshPatients() {
_listenToPatients();
}
void _listenToPatients() {
_firestoreService.getPatientsStream().listen((patientModels) async {
final patientsWithEmail = <Patient>[];
for (var model in patientModels) {
try {
final email = await _getGuardianEmail(model.nameGuardian);
final roomName = await _getRoomName(model.roomId);
patientsWithEmail.add(
Patient(
id: '1',
name: 'Mrs. Shinta',
room: 'Ruang Mawar 002',
deviceId: 'SI001',
guardian: 'Budi Santoso',
id: model.id,
name: model.namePatient,
guardian: model.nameGuardian,
deviceId: model.deviceId,
room: roomName,
email: email,
userId: null,
),
Patient(
id: '2',
name: 'Mr. Parjo',
room: 'Ruang Melati 001',
deviceId: 'SI002',
guardian: 'Siti Nurhaliza',
);
} catch (e) {
continue;
}
}
patients.value = patientsWithEmail;
}, onError: (error) => AppSnackbar.error('Gagal memuat data'));
}
Future<String> _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 '';
}
}
Future<String> _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;
}
}
Future<void> 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,
),
].obs;
);
void addPatient(Patient patient) {
patients.add(patient);
}
await _realtimeService.updateDeviceStatus(roomId, deviceId, 'connected');
await _createDeviceInRealtimeDB(roomId, deviceId);
void updatePatient(String id, Patient updatedPatient) {
final index = patients.indexWhere((p) => p.id == id);
if (index != -1) {
patients[index] = updatedPatient;
Get.back();
AppSnackbar.success('Pasien berhasil ditambahkan');
} catch (e) {
AppSnackbar.error('Gagal menambahkan pasien: $e');
} finally {
isLoading.value = false;
}
}
void deletePatient(String id) {
patients.removeWhere((patient) => patient.id == id);
Future<bool> _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<void> 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<void> _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<void> 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<void> _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<void> _createDevice(String deviceId, String roomId) async {
await _firestoreService.createDevice(
DeviceModel(
idDevices: deviceId,
roomId: roomId,
devicesName: 'Infusion Device $deviceId',
),
);
}
Future<void> _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<void> _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'^_|_$'), '');
}
}

View File

@ -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<ProfileController> {
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<ProfileController> {
Container(
decoration: BoxDecoration(
gradient: const LinearGradient(
colors: [
Color(0xFF0091EA),
Color(0xFF0277BD),
],
colors: [Color(0xFF0091EA), Color(0xFF0277BD)],
),
borderRadius: BorderRadius.circular(12),
boxShadow: [
@ -50,8 +45,11 @@ class ProfileView extends GetView<ProfileController> {
),
],
),
child: ElevatedButton(
onPressed: () {
child: Obx(
() => ElevatedButton(
onPressed: controller.isLoading.value
? null
: () {
Get.dialog(
const ModalPatientAddEdit(isEdit: false),
barrierDismissible: true,
@ -68,10 +66,23 @@ class ProfileView extends GetView<ProfileController> {
borderRadius: BorderRadius.circular(12),
),
),
child: const Row(
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),
Icon(
Icons.add,
color: Colors.white,
size: 20,
),
SizedBox(width: 8),
Text(
'Tambah',
@ -85,16 +96,30 @@ class ProfileView extends GetView<ProfileController> {
),
),
),
),
],
),
),
// Patient List
Expanded(
child: Obx(
() {
child: Obx(() {
if (controller.isLoading.value && controller.patients.isEmpty) {
return const Center(
child: CircularProgressIndicator(color: Color(0xFF0091EA)),
);
}
if (controller.patients.isEmpty) {
return Center(
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: [
@ -111,12 +136,30 @@ class ProfileView extends GetView<ProfileController> {
color: Colors.grey[600],
),
),
const SizedBox(height: 8),
Text(
'Tarik ke bawah untuk refresh',
style: TextStyle(
fontSize: 12,
color: Colors.grey[400],
),
),
],
),
),
),
),
);
}
return ListView.builder(
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<ProfileController> {
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,9 +237,9 @@ class ProfileView extends GetView<ProfileController> {
},
);
},
);
},
),
);
}),
),
],
),

View File

@ -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 = <PatientSchedule>[].obs;
final filteredSchedules = <PatientSchedule>[].obs;
final isLoading = false.obs;
final schedules = <PatientSchedule>[
StreamSubscription? _schedulesSubscription;
final Map<int, String> _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<PatientSchedule> 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: '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',
id: scheduleModel.id,
name: patient.namePatient,
room: roomName,
deviceId: patient.deviceId,
medicineTime: scheduleModel.medicineDetail,
fluidTime: scheduleModel.fluidDetail,
timeOfDay: scheduleModel.timeOfDay,
),
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;
);
}
} 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<String> _getRoomName(String roomId) async {
try {
final rooms = await _firestoreService.getRoomsStream().first;
final room = rooms.firstWhereOrNull(
(r) => r.id == roomId || r.roomName.toLowerCase() == roomId.toLowerCase(),
);
return room?.roomName ?? roomId;
} catch (e) {
return roomId;
}
}
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<void> addSchedule(PatientSchedule schedule, DateTime scheduleDate) async {
try {
final patients = await _firestoreService.getPatientsStream().first;
final patient = patients.firstWhereOrNull((p) => p.id == schedule.id);
if (patient == null) throw Exception('Pasien tidak ditemukan');
final scheduleModel = ScheduleModel(
id: '',
patientId: patient.id,
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<void> 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<void> deleteSchedule(String id) async {
try {
await _firestoreService.deleteSchedule(id);
AppSnackbar.success('Jadwal berhasil dihapus');
} catch (e) {
AppSnackbar.error('Gagal menghapus jadwal');
}
}
Future<List<Map<String, String>>> getPatientsList() async {
try {
final patients = await _firestoreService.getPatientsStream().first;
final List<Map<String, String>> 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 [];
}
}
}

View File

@ -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,21 +11,28 @@ class ScheduleView extends GetView<ScheduleController> {
@override
Widget build(BuildContext context) {
Get.put(ScheduleController());
return Scaffold(
backgroundColor: Colors.white,
body: SafeArea(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header Section
Container(
_buildHeader(context),
const SizedBox(height: 12),
Expanded(child: _buildScheduleList()),
],
),
),
);
}
Widget _buildHeader(BuildContext context) {
return Container(
color: Colors.white,
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Title
const Text(
'Schedule Obat',
style: TextStyle(
@ -36,26 +42,29 @@ class ScheduleView extends GetView<ScheduleController> {
),
),
const SizedBox(height: 16),
// Date Picker & Add Button
Row(
children: [
// Date Picker
Expanded(
child: GestureDetector(
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,
),
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,
),
border: Border.all(color: Colors.grey.shade300),
),
child: Row(
children: [
@ -79,19 +88,15 @@ class ScheduleView extends GetView<ScheduleController> {
),
),
const Spacer(),
Icon(
Icons.arrow_drop_down,
color: Colors.grey[600],
),
Icon(Icons.arrow_drop_down, color: Colors.grey[600]),
],
),
),
),
),
const SizedBox(width: 12),
);
}
// Add Button
Container(
Widget _buildAddButton() {
return Container(
decoration: BoxDecoration(
gradient: const LinearGradient(
colors: [Color(0xFF0091EA), Color(0xFF0277BD)],
@ -107,32 +112,23 @@ class ScheduleView extends GetView<ScheduleController> {
),
child: Material(
color: Colors.transparent,
child: // Ubah bagian tombol Add di schedule_view.dart:
InkWell(
onTap: () {
Get.dialog(
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,
child: Icon(Icons.add, color: Colors.white, size: 24),
),
),
),
),
),
],
),
const SizedBox(height: 16),
);
}
// Time Filter Tabs
Obx(
Widget _buildTimeFilterTabs() {
return Obx(
() => Row(
children: [
Expanded(child: _buildTabButton('Pagi', 0)),
@ -144,100 +140,6 @@ class ScheduleView extends GetView<ScheduleController> {
Expanded(child: _buildTabButton('Malam', 3)),
],
),
),
],
),
),
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),
),
),
],
),
);
},
);
},
);
}),
),
],
),
),
);
}
@ -252,7 +154,6 @@ class ScheduleView extends GetView<ScheduleController> {
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<ScheduleController> {
);
}
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<void> _selectDate(BuildContext context) async {
final DateTime? picked = await showDatePicker(
context: context,
@ -287,8 +304,6 @@ class ScheduleView extends GetView<ScheduleController> {
);
},
);
if (picked != null) {
controller.selectDate(picked);
}
if (picked != null) controller.selectDate(picked);
}
}

View File

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

View File

@ -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<bool> 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<String?> 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<void> 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<void> deleteUser(String uid) async {
await _firestore.collection('users').doc(uid).delete();
}
Future<void> 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<DocumentSnapshot?> 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<UserModel?> 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<bool> deviceExists(String deviceId) async {
try {
final doc = await _firestore.collection('devices').doc(deviceId).get();
return doc.exists;
} catch (e) {
return false;
}
}
Future<bool> 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<List<DeviceModel>> getDevicesStream() {
return _firestore
.collection('devices')
.snapshots()
.map(
(snapshot) => snapshot.docs
.map((doc) => DeviceModel.fromFirestore(doc))
.toList(),
);
}
Future<DeviceModel?> 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<void> createDevice(DeviceModel device) async {
await _firestore
.collection('devices')
.doc(device.idDevices)
.set(device.toFirestore());
}
Future<void> deleteDevice(String deviceId) async {
await _firestore.collection('devices').doc(deviceId).delete();
}
// ==================== PATIENT MANAGEMENT ====================
Stream<List<PatientModel>> getPatientsStream() {
return _firestore
.collection('patients')
.snapshots()
.map(
(snapshot) => snapshot.docs
.map((doc) => PatientModel.fromFirestore(doc))
.toList(),
);
}
Future<PatientModel?> 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<PatientModel?> 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<void> createPatient(PatientModel patient) async {
await _firestore.collection('patients').add(patient.toFirestore());
}
Future<void> updatePatient(String patientId, PatientModel patient) async {
await _firestore
.collection('patients')
.doc(patientId)
.update(patient.toFirestore());
}
Future<void> deletePatient(String patientId) async {
await _firestore.collection('patients').doc(patientId).delete();
}
// ==================== ROOM MANAGEMENT ====================
Future<bool> roomExists(String roomId) async {
try {
final doc = await _firestore.collection('rooms').doc(roomId).get();
return doc.exists;
} catch (e) {
return false;
}
}
Stream<List<RoomModel>> getRoomsStream() {
return _firestore
.collection('rooms')
.snapshots()
.map(
(snapshot) =>
snapshot.docs.map((doc) => RoomModel.fromFirestore(doc)).toList(),
);
}
Future<RoomModel?> 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<void> createRoom(RoomModel room) async {
await _firestore.collection('rooms').doc(room.id).set(room.toFirestore());
}
Future<void> deleteRoom(String roomId) async {
await _firestore.collection('rooms').doc(roomId).delete();
}
// ==================== HISTORY MANAGEMENT ====================
Stream<List<HistoryModel>> 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<List<HistoryModel>> 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<List<HistoryModel>> 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<List<HistoryModel>> 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<void> addHistory(HistoryModel history) async {
await _firestore.collection('histories').add(history.toFirestore());
}
Future<void> 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<void> 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<List<NotificationModel>> getNotificationsStream() {
return _firestore
.collection('notifications')
.orderBy('created_at', descending: true)
.snapshots()
.map(
(snapshot) => snapshot.docs
.map((doc) => NotificationModel.fromFirestore(doc))
.toList(),
);
}
Future<void> addNotification(NotificationModel notification) async {
await _firestore
.collection('notifications')
.add(notification.toFirestore());
}
Future<void> markNotificationAsRead(String notificationId) async {
await _firestore.collection('notifications').doc(notificationId).update({
'is_read': true,
});
}
Future<void> deleteNotification(String notificationId) async {
await _firestore.collection('notifications').doc(notificationId).delete();
}
// BARU: Hapus semua notifications berdasarkan device_id
Future<void> 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<List<ScheduleModel>> 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<void> addSchedule(ScheduleModel schedule) async {
await _firestore.collection('schedules').add(schedule.toFirestore());
}
Future<void> updateSchedule(String scheduleId, ScheduleModel schedule) async {
await _firestore
.collection('schedules')
.doc(scheduleId)
.update(schedule.toFirestore());
}
Future<void> deleteSchedule(String scheduleId) async {
await _firestore.collection('schedules').doc(scheduleId).delete();
}
// BARU: Hapus semua schedules berdasarkan device_id
Future<void> 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();
}
}

View File

@ -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<Map<String, RealtimeMonitoringModel>> getRoomDevicesStream(
String roomId,
) {
return _database
.child('realtime/rooms/$roomId/devices')
.onValue
.map((event) {
final Map<String, RealtimeMonitoringModel> devices = {};
if (event.snapshot.value != null) {
final data = event.snapshot.value as Map<dynamic, dynamic>;
data.forEach((deviceId, deviceData) {
if (deviceData is Map) {
devices[deviceId] = RealtimeMonitoringModel.fromRealtimeDB(
deviceId,
roomId,
deviceData as Map<dynamic, dynamic>,
);
}
});
}
return devices;
});
}
Stream<RealtimeMonitoringModel?> 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<dynamic, dynamic>;
return RealtimeMonitoringModel.fromRealtimeDB(deviceId, roomId, data);
}
return null;
});
}
Stream<List<RealtimeMonitoringModel>> getAllDevicesStream() {
return _database.child('realtime/rooms').onValue.map((event) {
final List<RealtimeMonitoringModel> allDevices = [];
if (event.snapshot.value != null) {
final rooms = event.snapshot.value as Map<dynamic, dynamic>;
rooms.forEach((roomId, roomData) {
if (roomData is Map && roomData['devices'] != null) {
final devices = roomData['devices'] as Map<dynamic, dynamic>;
devices.forEach((deviceId, deviceData) {
if (deviceData is Map) {
allDevices.add(
RealtimeMonitoringModel.fromRealtimeDB(
deviceId,
roomId,
deviceData as Map<dynamic, dynamic>,
),
);
}
});
}
});
}
return allDevices;
});
}
Future<RealtimeMonitoringModel?> 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<dynamic, dynamic>;
return RealtimeMonitoringModel.fromRealtimeDB(deviceId, roomId, data);
}
return null;
} catch (e) {
return null;
}
}
// ==================== CONTROL ====================
Future<void> 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<void> 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<void> 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<void> 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<bool> isDeviceConnected(String roomId, String deviceId) async {
try {
final device = await getDevice(roomId, deviceId);
return device?.deviceStatus == 'connected';
} catch (e) {
return false;
}
}
Future<double> 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;
}
}
}

View File

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

View File

@ -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<ChartDataPoint> dataPoints; // Ubah dari dynamic ke List<ChartDataPoint>
final List<ChartDataPoint> 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(
@ -31,9 +64,6 @@ class HistoryGrafikCard extends StatelessWidget {
],
),
child: Column(
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
@ -56,11 +86,7 @@ class HistoryGrafikCard extends StatelessWidget {
),
],
),
],
),
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(),

View File

@ -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<ModalPatientAddEdit> createState() => _ModalPatientAddEditState();
@ -24,22 +19,25 @@ class _ModalPatientAddEditState extends State<ModalPatientAddEdit> {
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<ModalPatientAddEdit> {
_roomController.dispose();
_deviceIdController.dispose();
_guardianController.dispose();
_emailController.dispose();
_passwordController.dispose();
super.dispose();
}
@ -58,7 +58,21 @@ class _ModalPatientAddEditState extends State<ModalPatientAddEdit> {
insetPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 40),
child: Container(
constraints: const BoxConstraints(maxWidth: 500),
decoration: BoxDecoration(
decoration: _dialogDecoration(),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
_buildHeader(),
_buildForm(),
_buildFooter(),
],
),
),
);
}
BoxDecoration _dialogDecoration() {
return BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(24),
boxShadow: [
@ -68,36 +82,24 @@ class _ModalPatientAddEditState extends State<ModalPatientAddEdit> {
offset: const Offset(0, 10),
),
],
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Header
Container(
);
}
Widget _buildHeader() {
return Container(
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
gradient: const LinearGradient(
decoration: const BoxDecoration(
gradient: LinearGradient(
colors: [Color(0xFF0091EA), Color(0xFF0277BD)],
),
borderRadius: const BorderRadius.only(
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.person_add,
color: Colors.white,
size: 24,
),
),
_buildHeaderIcon(),
const SizedBox(width: 16),
Expanded(
child: Text(
@ -110,16 +112,32 @@ class _ModalPatientAddEditState extends State<ModalPatientAddEdit> {
),
),
IconButton(
onPressed: () => Get.back(),
onPressed: _isSaving ? null : () => Get.back(),
icon: const Icon(Icons.close, color: Colors.white),
splashRadius: 20,
),
],
),
),
);
}
// Form Content
Flexible(
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(
@ -127,76 +145,171 @@ class _ModalPatientAddEditState extends State<ModalPatientAddEdit> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Nama Pasien
_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) {
if (value == null || value.isEmpty) {
return 'Nama pasien harus diisi';
}
return null;
},
validator: (value) => value?.isEmpty ?? true ? 'Wajib diisi' : 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),
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) {
if (value == null || value.isEmpty) {
return 'Wali pasien harus diisi';
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;
},
),
],
),
),
),
),
);
}
// Bottom Buttons
Container(
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],
@ -207,29 +320,35 @@ class _ModalPatientAddEditState extends State<ModalPatientAddEdit> {
),
child: Row(
children: [
Expanded(
child: OutlinedButton(
onPressed: () => Get.back(),
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),
),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
),
child: Text(
'Batal',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: Colors.grey[700],
color: _isSaving ? Colors.grey[400] : Colors.grey[700],
),
),
),
),
const SizedBox(width: 12),
Expanded(
child: Container(
);
}
Widget _buildSaveButton() {
return Container(
decoration: BoxDecoration(
gradient: const LinearGradient(
colors: [Color(0xFF0091EA), Color(0xFF0277BD)],
@ -244,16 +363,20 @@ class _ModalPatientAddEditState extends State<ModalPatientAddEdit> {
],
),
child: ElevatedButton(
onPressed: _handleSave,
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),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
),
),
child: Text(
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,
@ -262,14 +385,6 @@ class _ModalPatientAddEditState extends State<ModalPatientAddEdit> {
),
),
),
),
),
],
),
),
],
),
),
);
}
@ -295,15 +410,23 @@ class _ModalPatientAddEditState extends State<ModalPatientAddEdit> {
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<ModalPatientAddEdit> {
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<ModalPatientAddEdit> {
);
}
void _handleSave() {
if (_formKey.currentState!.validate()) {
Future<void> _handleSave() async {
if (!_formKey.currentState!.validate()) return;
setState(() => _isSaving = true);
final controller = Get.find<ProfileController>();
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(),
await controller.updatePatient(
patientId: widget.patient!.id,
patientName: _nameController.text.trim(),
guardianName: _guardianController.text.trim(),
roomName: _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),
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(),
await controller.addPatient(
patientName: _nameController.text.trim(),
guardianName: _guardianController.text.trim(),
roomName: _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),
email: _emailController.text.trim(),
password: _passwordController.text.trim(),
);
}
} catch (e) {
setState(() => _isSaving = false);
}
}
}

View File

@ -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<ModalScheduleAddEdit> {
final _formKey = GlobalKey<FormState>();
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<Map<String, String>> _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<Map<String, String>> _patients = [];
bool _isLoadingPatients = true;
// Waktu
final List<String> _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<void> _loadPatients() async {
try {
final controller = Get.find<ScheduleController>();
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<ModalScheduleAddEdit> {
}
Map<String, String>? 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,14 +95,81 @@ class _ModalScheduleAddEditState extends State<ModalScheduleAddEdit> {
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Header
Container(
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
gradient: const LinearGradient(
colors: [Color(0xFF0091EA), Color(0xFF0277BD)],
_buildHeader(),
Flexible(
child: _isLoadingPatients
? const Center(
child: Padding(
padding: EdgeInsets.all(40),
child: CircularProgressIndicator(color: Color(0xFF0091EA)),
),
borderRadius: const BorderRadius.only(
)
: 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;
},
),
],
),
),
),
),
_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),
),
@ -121,11 +182,7 @@ class _ModalScheduleAddEditState extends State<ModalScheduleAddEdit> {
color: Colors.white.withOpacity(0.2),
borderRadius: BorderRadius.circular(12),
),
child: const Icon(
Icons.calendar_today,
color: Colors.white,
size: 24,
),
child: const Icon(Icons.calendar_today, color: Colors.white, size: 24),
),
const SizedBox(width: 16),
Expanded(
@ -145,147 +202,6 @@ class _ModalScheduleAddEditState extends State<ModalScheduleAddEdit> {
),
],
),
),
// Form Content
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;
},
),
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],
),
),
),
),
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,
),
),
),
),
),
],
),
),
],
),
),
);
}
@ -306,9 +222,80 @@ class _ModalScheduleAddEditState extends State<ModalScheduleAddEdit> {
);
}
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<String>(
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<ModalScheduleAddEdit> {
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: Colors.redAccent),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 14,
),
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
),
icon: const Icon(Icons.arrow_drop_down, color: Color(0xFF0091EA)),
isExpanded: true,
@ -352,7 +336,7 @@ class _ModalScheduleAddEditState extends State<ModalScheduleAddEdit> {
}).toList(),
onChanged: (value) {
setState(() {
_selectedPatient = value;
_selectedPatientId = value;
});
},
validator: (value) {
@ -385,19 +369,13 @@ Widget _buildSelectedPatientInfo() {
color: const Color(0xFF0091EA).withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
),
child: const Icon(
Icons.person,
color: Color(0xFF0091EA),
size: 20,
),
child: const Icon(Icons.person, color: Color(0xFF0091EA), size: 20),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// ===== NAMA =====
Text(
patient['name']!,
style: const TextStyle(
@ -407,16 +385,10 @@ Widget _buildSelectedPatientInfo() {
),
),
const SizedBox(height: 6),
// ===== ROOM + DEVICE ID =====
Row(
children: [
// ROOM
Container(
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 4,
),
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: const Color(0xFFE3F2FD),
borderRadius: BorderRadius.circular(6),
@ -430,15 +402,9 @@ Widget _buildSelectedPatientInfo() {
),
),
),
const SizedBox(width: 8),
// DEVICE ID
Container(
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 4,
),
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: Colors.grey[100],
borderRadius: BorderRadius.circular(6),
@ -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<ScheduleController>();
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(),
),
);
controller.addSchedule(schedule, _selectedDate!);
}
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),
);
}
}
}
}

View File

@ -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
_buildIcon(isConnected),
const SizedBox(width: 16),
Expanded(child: _buildContent(isConnected)),
],
),
),
);
}
Widget _buildIcon(bool isConnected) {
return Stack(
children: [
Container(
padding: const EdgeInsets.all(12),
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: const Color(0xFFE3F2FD),
borderRadius: BorderRadius.circular(14),
),
child: const Icon(
Icons.water_drop_outlined,
size: 36,
size: 32,
color: Color(0xFF2196F3),
),
),
const SizedBox(width: 16),
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),
),
),
),
],
);
}
// RIGHT CONTENT
Expanded(
child: Column(
Widget _buildContent(bool isConnected) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// DROPS
Row(
_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(
item.dropsPerMinute.split(' ')[0],
monitoring.dropsPerMinute.replaceAll(' tpm', ''),
style: const TextStyle(
fontSize: 24,
fontSize: 22,
fontWeight: FontWeight.bold,
color: Color(0xFF2196F3),
height: 1,
),
),
const SizedBox(width: 6),
Padding(
padding: const EdgeInsets.only(bottom: 3),
const Padding(
padding: EdgeInsets.only(bottom: 2),
child: Text(
'tetes/menit',
'tetes per menit',
style: TextStyle(
fontSize: 13,
color: Colors.grey.shade600,
fontWeight: FontWeight.w600,
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),
),
const SizedBox(height: 6),
// LAST UPDATE
Text(
'Last update • ${Get.find<HomeController>().getLastUpdateText(item.lastUpdate)}',
style: TextStyle(fontSize: 11, color: Colors.grey.shade600),
TextSpan(
text: monitoring.patientName,
style: const TextStyle(
fontWeight: FontWeight.w600,
color: Colors.black87,
),
),
],
),
);
}
const SizedBox(height: 12),
// ROOM & DEVICE
Wrap(
Widget _buildBadges(bool isConnected) {
return Wrap(
spacing: 8,
runSpacing: 6,
children: [
// ROOM BADGE
Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 6,
_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: const Color(0xFFE3F2FD),
borderRadius: BorderRadius.circular(8),
color: bgColor,
borderRadius: BorderRadius.circular(6),
),
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,
text,
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,
),
),
],
),
),
],
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';
}
}

View File

@ -1,4 +1,3 @@
// lib/app/widgets/schedule_card.dart
import 'package:flutter/material.dart';
import '../modules/schedule/controllers/schedule_controller.dart';
@ -34,8 +33,18 @@ class ScheduleCard extends StatelessWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// ================= HEADER =================
Row(
_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(
@ -51,15 +60,10 @@ class ScheduleCard extends StatelessWidget {
),
),
const SizedBox(height: 6),
Row(
children: [
// ===== ROOM =====
Container(
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 4,
),
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: const Color(0xFFE3F2FD),
borderRadius: BorderRadius.circular(6),
@ -73,15 +77,9 @@ class ScheduleCard extends StatelessWidget {
),
),
),
const SizedBox(width: 8),
// ===== DEVICE ID =====
Container(
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 4,
),
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: Colors.grey[100],
borderRadius: BorderRadius.circular(6),
@ -99,7 +97,7 @@ class ScheduleCard extends StatelessWidget {
),
const SizedBox(width: 6),
Text(
schedule.deviceId, // pastikan ada di model
schedule.deviceId,
style: TextStyle(
fontSize: 11,
color: Colors.grey[700],
@ -114,11 +112,8 @@ class ScheduleCard extends StatelessWidget {
],
),
),
/// ACTION BUTTONS
Row(
children: [
// ===== EDIT =====
InkWell(
onTap: onEditTap,
borderRadius: BorderRadius.circular(8),
@ -128,16 +123,10 @@ class ScheduleCard extends StatelessWidget {
color: Colors.amber.withOpacity(0.15),
borderRadius: BorderRadius.circular(8),
),
child: const Icon(
Icons.edit,
size: 18,
color: Colors.amber,
),
child: const Icon(Icons.edit, size: 18, color: Colors.amber),
),
),
const SizedBox(width: 8),
// ===== DELETE =====
InkWell(
onTap: onDeleteTap,
borderRadius: BorderRadius.circular(8),
@ -147,29 +136,18 @@ class ScheduleCard extends StatelessWidget {
color: Colors.redAccent.withOpacity(0.15),
borderRadius: BorderRadius.circular(8),
),
child: const Icon(
Icons.delete,
size: 18,
color: Colors.redAccent,
),
child: const Icon(Icons.delete, size: 18, color: Colors.redAccent),
),
),
],
),
],
),
);
}
const SizedBox(height: 12),
// ================= DIVIDER =================
Container(height: 1, color: Colors.grey.shade200),
const SizedBox(height: 16),
// ================= MEDICINE & FLUID =================
Row(
Widget _buildMedicineAndFluid() {
return Row(
children: [
// -------- Medicine --------
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
@ -182,15 +160,11 @@ class ScheduleCard extends StatelessWidget {
color: const Color(0xFF2196F3).withOpacity(0.1),
borderRadius: BorderRadius.circular(6),
),
child: const Icon(
Icons.medication,
size: 16,
color: Color(0xFF2196F3),
),
child: const Icon(Icons.medication, size: 16, color: Color(0xFF2196F3)),
),
const SizedBox(width: 8),
Text(
schedule.medicineType,
'Obat & Dosis',
style: TextStyle(
color: Colors.grey[600],
fontSize: 11,
@ -214,10 +188,7 @@ class ScheduleCard extends StatelessWidget {
],
),
),
const SizedBox(width: 16),
// -------- Fluid --------
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
@ -230,15 +201,11 @@ class ScheduleCard extends StatelessWidget {
color: const Color(0xFF00BCD4).withOpacity(0.1),
borderRadius: BorderRadius.circular(6),
),
child: const Icon(
Icons.water_drop,
size: 16,
color: Color(0xFF00BCD4),
),
child: const Icon(Icons.water_drop, size: 16, color: Color(0xFF00BCD4)),
),
const SizedBox(width: 8),
Text(
schedule.fluidType,
'Cairan & Tetes',
style: TextStyle(
color: Colors.grey[600],
fontSize: 11,
@ -263,9 +230,6 @@ class ScheduleCard extends StatelessWidget {
),
),
],
),
],
),
);
}
}

View File

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

View File

@ -49,6 +49,7 @@ dependencies:
dev_dependencies:
flutter_test:
sdk: flutter