upload projek

This commit is contained in:
bimabuana 2026-07-15 05:11:48 +07:00
parent ae51f1a835
commit 41c9aaf4f2
35 changed files with 328 additions and 219 deletions

5
.gitignore vendored
View File

@ -91,3 +91,8 @@ key.properties
.env.*
lib/core/env.dart
# Markdown files
*.md
!README.md
**/*.md
!**/README.md

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

After

Width:  |  Height:  |  Size: 453 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 737 KiB

After

Width:  |  Height:  |  Size: 226 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

After

Width:  |  Height:  |  Size: 453 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 737 KiB

After

Width:  |  Height:  |  Size: 226 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 MiB

After

Width:  |  Height:  |  Size: 731 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.8 MiB

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.4 MiB

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 MiB

After

Width:  |  Height:  |  Size: 731 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.8 MiB

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.4 MiB

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.4 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.1 KiB

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 MiB

After

Width:  |  Height:  |  Size: 490 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.1 MiB

After

Width:  |  Height:  |  Size: 1.8 MiB

View File

@ -4,7 +4,7 @@
## Paragraf 1 — Gambaran Arsitektur Sistem
Aplikasi JTA merupakan komponen antarmuka (*frontend*) dalam sebuah ekosistem IoT yang terdiri dari empat lapisan utama: perangkat keras sensor (ESP32 + DHT22), server backend (Node.js di Railway), layanan database dan autentikasi (Supabase), serta aplikasi mobile Flutter sebagai ujung akses pengguna. Data sensor mengalir dari ESP32 melalui protokol MQTT menuju backend, kemudian disimpan ke database Supabase. Aplikasi Flutter mengambil data tersebut melalui REST API dan menampilkannya secara near real-time kepada pengguna. Lingkup pengembangan pada laporan ini berfokus pada **aplikasi mobile Flutter** sebagai komponen antarmuka sistem.
Aplikasi JTA merupakan komponen antarmuka (*frontend*) dalam sebuah ekosistem IoT yang terdiri dari empat lapisan utama: perangkat keras sensor (ESP32 + SHT31), server backend (Node.js di Railway), layanan database dan autentikasi (Supabase), serta aplikasi mobile Flutter sebagai ujung akses pengguna. Data sensor mengalir dari ESP32 melalui protokol MQTT menuju backend, kemudian disimpan ke database Supabase. Aplikasi Flutter mengambil data tersebut melalui REST API dan menampilkannya secara near real-time kepada pengguna. Lingkup pengembangan pada laporan ini berfokus pada **aplikasi mobile Flutter** sebagai komponen antarmuka sistem.
![Gambaran Umum Arsitektur Sistem](C:/Users/user/.gemini/antigravity/brain/f5437a05-a1d5-4e3b-aa37-20e3f692fed9/diagram_arsitektur_overview_1778563146372.png)

View File

@ -74,7 +74,7 @@ Berikut adalah penjelasan mengenai aktor-aktor yang terlibat di dalam sistem JTA
| No | Nama Aktor | Jenis | Deskripsi |
|---|---|---|---|
| 1 | **Petani (Pengguna)** | Utama (*Primary*) | Pengguna akhir aplikasi yang melakukan monitoring suhu/kelembapan kumbung dan mengendalikan pompa penyiraman secara manual maupun otomatis melalui aplikasi mobile. |
| 2 | **Perangkat IoT (ESP32)** | Pendukung (*Supporting*) | Perangkat keras yang mengirimkan data sensor DHT22 ke sistem dan menerima perintah kontrol (on/off relay pompa) serta mengirimkan data status aktual pompa. |
| 2 | **Perangkat IoT (ESP32)** | Pendukung (*Supporting*) | Perangkat keras yang mengirimkan data sensor SHT31 ke sistem dan menerima perintah kontrol (on/off relay pompa) serta mengirimkan data status aktual pompa. |
---

View File

@ -35,8 +35,8 @@ class AppRoutes {
static PageRouteBuilder<dynamic> _fadeRoute(Widget page) {
return PageRouteBuilder(
transitionDuration: const Duration(milliseconds: 400),
pageBuilder: (_, __, ___) => page,
transitionsBuilder: (_, animation, __, child) {
pageBuilder: (context, state, childState) => page,
transitionsBuilder: (context, animation, secondaryAnimation, child) {
return FadeTransition(
opacity: CurvedAnimation(parent: animation, curve: Curves.easeInOut),
child: child,

View File

@ -159,13 +159,7 @@ class ControlPage extends ConsumerWidget {
}
bool _checkIsOnline(WidgetRef ref) {
final monitoringState = ref.watch(monitoringProvider);
final latest = monitoringState.latest;
if (latest == null) return false;
// Jika data terakhir masuk dalam 5 menit, dianggap online
final diff = DateTime.now().difference(latest.timestamp);
return diff.inMinutes < 5;
return ref.watch(monitoringProvider).isDeviceOnline;
}
@override
@ -225,7 +219,7 @@ class ControlPage extends ConsumerWidget {
borderRadius: BorderRadius.circular(24),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.04),
color: Colors.black.withValues(alpha: 0.04),
blurRadius: 16,
offset: const Offset(0, 4),
),
@ -309,6 +303,7 @@ class ControlPage extends ConsumerWidget {
),
],
const SizedBox(height: 20),
_buildWateringReferenceCard(),
],
],
),
@ -316,4 +311,145 @@ class ControlPage extends ConsumerWidget {
),
);
}
Widget _buildWateringReferenceCard() {
return Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(20),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.02),
blurRadius: 10,
offset: const Offset(0, 2),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
padding: const EdgeInsets.all(6),
decoration: const BoxDecoration(
color: Color(0xFFE0F2F1), // Light teal background matching theme
shape: BoxShape.circle,
),
child: const Icon(Icons.info_outline, color: AppTheme.primary, size: 16),
),
const SizedBox(width: 10),
const Text(
'Panduan Penyiraman Terbaik',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
color: AppTheme.textPrimary,
),
),
],
),
const SizedBox(height: 12),
const Text(
'Untuk menjaga kualitas tumbuh kembang Jamur Tiram, berikut adalah rekomendasi waktu & frekuensi penyiraman otomatis/manual:',
style: TextStyle(fontSize: 12, color: AppTheme.textSecondary, height: 1.4),
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: _buildWateringIndicator(
icon: Icons.schedule,
iconColor: Colors.orange,
label: 'Waktu Terbaik',
value: 'Pagi & Sore',
),
),
const SizedBox(width: 12),
Expanded(
child: _buildWateringIndicator(
icon: Icons.loop,
iconColor: Colors.blue,
label: 'Frekuensi Ideal',
value: '2 - 3 Kali / Hari',
),
),
],
),
const SizedBox(height: 12),
Divider(color: Colors.grey.shade100),
const SizedBox(height: 8),
const Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(Icons.tips_and_updates_outlined, size: 16, color: Colors.orangeAccent),
SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Tips Penyiraman:',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: AppTheme.textPrimary,
),
),
SizedBox(height: 4),
Text(
'• Pagi (06.00-08.00 WIB) & Sore (16.00-18.00 WIB) membantu menyeimbangkan penguapan air di siang hari.\n• Hindari penyiraman berlebih jika kelembapan kumbung sudah di atas 80% atau saat hujan lebat agar baglog tidak membusuk.',
style: TextStyle(fontSize: 11, color: AppTheme.textSecondary, height: 1.5),
),
],
),
),
],
),
],
),
);
}
Widget _buildWateringIndicator({
required IconData icon,
required Color iconColor,
required String label,
required String value,
}) {
return Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: iconColor.withValues(alpha: 0.05),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: iconColor.withValues(alpha: 0.1)),
),
child: Row(
children: [
Icon(icon, color: iconColor, size: 20),
const SizedBox(width: 8),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: const TextStyle(fontSize: 9, color: AppTheme.textSecondary),
),
const SizedBox(height: 2),
Text(
value,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: iconColor.withValues(alpha: 0.9),
),
),
],
),
],
),
);
}
}

View File

@ -167,7 +167,7 @@ class MonitoringPage extends ConsumerWidget {
),
] else ...[
DeviceStatusCard(
isOnline: monitoring.latest != null && DateTime.now().difference(monitoring.latest!.timestamp).inMinutes < 5,
isOnline: monitoring.isDeviceOnline,
deviceCode: _getDeviceCode(ref),
lastSeen: monitoring.latest?.timestamp,
),
@ -195,6 +195,7 @@ class MonitoringPage extends ConsumerWidget {
subtitle: 'Suhu Real Time',
status: monitoring.tempStatus(control.suhuMinLimit, control.suhuMaxLimit),
gradient: AppTheme.suhuGradient,
timestamp: monitoring.latest?.timestamp,
),
),
const SizedBox(width: 16),
@ -205,6 +206,7 @@ class MonitoringPage extends ConsumerWidget {
subtitle: 'Kelembapan Real Time',
status: monitoring.humidStatus(control.kelembapanLimit),
gradient: AppTheme.humidGradient,
timestamp: monitoring.latest?.timestamp,
),
),
],

View File

@ -1,3 +1,4 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
@ -22,7 +23,7 @@ class _KumbungMapPageState extends ConsumerState<KumbungMapPage>
double _sensorX = 4.0;
double _sensorY = 3.0;
final double _sensorZ = 1.2; // Tinggi melayang sensor di rak
double _sensorRadius = 3.0;
final double _sensorRadius = 2.0; // Radius jangkauan fiks 2.0 meter
// Mode Heatmap: true = Suhu, false = Kelembapan
bool _isSuhuMode = true;
@ -48,9 +49,8 @@ class _KumbungMapPageState extends ConsumerState<KumbungMapPage>
Future<void> _loadSensorSettings() async {
final prefs = await SharedPreferences.getInstance();
setState(() {
_sensorX = prefs.getDouble('sensor_x') ?? 4.0;
_sensorY = prefs.getDouble('sensor_y') ?? 3.0;
_sensorRadius = prefs.getDouble('sensor_radius') ?? 3.0;
_sensorX = (prefs.getDouble('sensor_x') ?? 4.0).clamp(0.5, 7.5);
_sensorY = (prefs.getDouble('sensor_y') ?? 3.0).clamp(0.5, 5.5);
});
}
@ -59,16 +59,16 @@ class _KumbungMapPageState extends ConsumerState<KumbungMapPage>
final prefs = await SharedPreferences.getInstance();
await prefs.setDouble('sensor_x', _sensorX);
await prefs.setDouble('sensor_y', _sensorY);
await prefs.setDouble('sensor_radius', _sensorRadius);
}
// Mengubah posisi sensor saat canvas diketuk oleh pengguna
void _handleCanvasTap(Offset localPosition, Size canvasSize, IsometricTransform transformer) {
final worldPt = transformer.toWorldGround(localPosition);
// Batasi agar koordinat berada di dalam rentang ruangan (8m x 6m)
final double clampedX = worldPt.dx.clamp(0.0, 8.0);
final double clampedY = worldPt.dy.clamp(0.0, 6.0);
// Batasi agar koordinat berada di dalam rentang slider (0.5 s.d 7.5 untuk X, 0.5 s.d 5.5 untuk Y)
// guna menghindari crash assertion Slider jika user mengetuk area luar grid lantai.
final double clampedX = worldPt.dx.clamp(0.5, 7.5);
final double clampedY = worldPt.dy.clamp(0.5, 5.5);
setState(() {
_sensorX = clampedX;
@ -173,14 +173,27 @@ class _KumbungMapPageState extends ConsumerState<KumbungMapPage>
// Canvas Utama Rendering Isometrik
LayoutBuilder(
builder: (context, constraints) {
// Menghitung origin tengah bawah untuk visualisasi isometrik
const double roomWidth = 6.0;
const double roomLength = 8.0;
const double roomHeight = 2.5;
const double scale = 24.0;
// Menghitung origin tengah agar visualisasi isometrik berada tepat di pusat canvas
const double angle = 30.0 * math.pi / 180.0;
final double cos30 = math.cos(angle);
final double sin30 = math.sin(angle);
final double dxCenterOffset = (roomLength - roomWidth) / 2 * cos30 * scale;
final double dyCenterOffset = ((roomLength + roomWidth) * sin30 - roomHeight) / 2 * scale;
final centerOrigin = Offset(
constraints.maxWidth / 2,
constraints.maxHeight / 2 + 30,
constraints.maxWidth / 2 - dxCenterOffset,
constraints.maxHeight / 2 - dyCenterOffset,
);
// Skala dinamis: 1 meter = 24 piksel
final transformer = IsometricTransform(
scale: 24.0,
scale: scale,
origin: centerOrigin,
);
@ -197,9 +210,9 @@ class _KumbungMapPageState extends ConsumerState<KumbungMapPage>
size: Size(constraints.maxWidth, constraints.maxHeight),
painter: StaticKumbungPainter(
transformer: transformer,
roomWidth: 6.0,
roomLength: 8.0,
roomHeight: 2.5,
roomWidth: roomWidth,
roomLength: roomLength,
roomHeight: roomHeight,
),
foregroundPainter: PinKumbungPainter(
transformer: transformer,
@ -280,76 +293,6 @@ class _KumbungMapPageState extends ConsumerState<KumbungMapPage>
),
const SizedBox(height: 16),
// Control Settings Panel
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(20),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.02),
blurRadius: 10,
offset: const Offset(0, 2),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(Icons.settings_input_hdmi, color: AppTheme.primary, size: 18),
const SizedBox(width: 8),
Text(
'Konfigurasi Sensor Terpasang',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
color: Colors.grey.shade800,
),
),
],
),
const SizedBox(height: 12),
_buildSlider(
label: 'Posisi X (Panjang)',
value: _sensorX,
min: 0.5,
max: 7.5,
displayValue: '${_sensorX.toStringAsFixed(1)}m',
onChanged: (v) {
setState(() => _sensorX = v);
_saveSensorSettings();
},
),
_buildSlider(
label: 'Posisi Y (Lebar)',
value: _sensorY,
min: 0.5,
max: 5.5,
displayValue: '${_sensorY.toStringAsFixed(1)}m',
onChanged: (v) {
setState(() => _sensorY = v);
_saveSensorSettings();
},
),
_buildSlider(
label: 'Radius Jangkauan',
value: _sensorRadius,
min: 1.0,
max: 5.0,
displayValue: '${_sensorRadius.toStringAsFixed(1)}m',
onChanged: (v) {
setState(() => _sensorRadius = v);
_saveSensorSettings();
},
),
],
),
),
const SizedBox(height: 16),
// Dashboard Referensi Budidaya
_buildReferenceCard(),
],
@ -387,60 +330,7 @@ class _KumbungMapPageState extends ConsumerState<KumbungMapPage>
);
}
// Widget Row Slider Pengaturan
Widget _buildSlider({
required String label,
required double value,
required double min,
required double max,
required String displayValue,
required ValueChanged<double> onChanged,
}) {
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Row(
children: [
Expanded(
flex: 3,
child: Text(
label,
style: const TextStyle(fontSize: 12, color: AppTheme.textSecondary),
),
),
Expanded(
flex: 6,
child: SliderTheme(
data: SliderTheme.of(context).copyWith(
activeTrackColor: AppTheme.primary,
inactiveTrackColor: Colors.grey.shade200,
thumbColor: AppTheme.primary,
trackHeight: 3,
thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 6),
),
child: Slider(
value: value,
min: min,
max: max,
onChanged: onChanged,
),
),
),
Expanded(
flex: 2,
child: Text(
displayValue,
textAlign: TextAlign.right,
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: AppTheme.textPrimary,
),
),
),
],
),
);
}
// Widget Kartu Referensi Budidaya Jamur
Widget _buildReferenceCard() {
@ -452,7 +342,7 @@ class _KumbungMapPageState extends ConsumerState<KumbungMapPage>
borderRadius: BorderRadius.circular(20),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.02),
color: Colors.black.withValues(alpha: 0.02),
blurRadius: 10,
offset: const Offset(0, 2),
),
@ -466,7 +356,7 @@ class _KumbungMapPageState extends ConsumerState<KumbungMapPage>
Container(
padding: const EdgeInsets.all(6),
decoration: BoxDecoration(
color: AppTheme.primary.withOpacity(0.1),
color: AppTheme.primary.withValues(alpha: 0.1),
shape: BoxShape.circle,
),
child: const Icon(Icons.menu_book, color: AppTheme.primary, size: 16),
@ -495,7 +385,7 @@ class _KumbungMapPageState extends ConsumerState<KumbungMapPage>
icon: Icons.thermostat,
iconColor: Colors.teal,
label: 'Suhu Ideal',
value: '22°C - 28°C',
value: '20°C - 33°C',
),
),
const SizedBox(width: 12),
@ -515,14 +405,14 @@ class _KumbungMapPageState extends ConsumerState<KumbungMapPage>
const Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(Icons.wb_sunny_outlined, size: 16, color: Colors.orange),
Icon(Icons.info_outline, size: 16, color: AppTheme.primary),
SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Waktu Penyiraman Terbaik:',
'Informasi Suhu & Kelembapan:',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
@ -531,7 +421,7 @@ class _KumbungMapPageState extends ConsumerState<KumbungMapPage>
),
SizedBox(height: 4),
Text(
'• Pagi Hari (06.00 - 08.00 WIB): Mempersiapkan kelembapan sebelum suhu udara mulai meningkat.\n• Sore Hari (16.00 - 18.00 WIB): Mengembalikan kelembapan setelah penguapan tinggi di siang hari.',
'Suhu yang stabil mencegah stres pada tubuh buah jamur, sedangkan kelembapan yang tinggi (di atas 80%) sangat penting untuk memicu pertumbuhan pinhead (primordia) secara maksimal.',
style: TextStyle(fontSize: 11, color: AppTheme.textSecondary, height: 1.5),
),
],
@ -553,9 +443,9 @@ class _KumbungMapPageState extends ConsumerState<KumbungMapPage>
return Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: iconColor.withOpacity(0.05),
color: iconColor.withValues(alpha: 0.05),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: iconColor.withOpacity(0.1)),
border: Border.all(color: iconColor.withValues(alpha: 0.1)),
),
child: Row(
children: [
@ -574,7 +464,7 @@ class _KumbungMapPageState extends ConsumerState<KumbungMapPage>
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: iconColor.withOpacity(0.9),
color: iconColor.withValues(alpha: 0.9),
),
),
],

View File

@ -74,7 +74,7 @@ class _MainPageState extends ConsumerState<MainPage> {
BottomNavigationBarItem(
icon: Icon(Icons.map_outlined),
activeIcon: Icon(Icons.map),
label: 'Peta',
label: 'Heat Map',
),
BottomNavigationBarItem(
icon: Icon(Icons.tune_outlined),

View File

@ -113,7 +113,7 @@ class ControlNotifier extends Notifier<ControlState> {
isLoading: false,
errorMessage: 'Gagal memuat data awal. Periksa koneksi internet.',
);
print('Gagal _initData Control: $e');
debugPrint('Gagal _initData Control: $e');
}
}
@ -153,7 +153,7 @@ class ControlNotifier extends Notifier<ControlState> {
// 3. Batalkan/Revert status tombol jika request API mengalami kegagalan
_syncLockedUntil = null;
state = state.copyWith(isPumpOn: !willTurnOn, errorMessage: 'Gagal mengontrol pompa');
print('Gagal toggle pompa: $e');
debugPrint('Gagal toggle pompa: $e');
}
}
@ -213,7 +213,7 @@ class ControlNotifier extends Notifier<ControlState> {
kelembapanLimit: oldKelembapan,
errorMessage: 'Gagal menyimpan batasan sensor',
);
print('Gagal save threshold: $e');
debugPrint('Gagal save threshold: $e');
}
}
@ -258,7 +258,7 @@ class ControlNotifier extends Notifier<ControlState> {
);
} catch (e) {
state = state.copyWith(isLoading: false, errorMessage: 'Gagal menambah jadwal');
print('Gagal tambah schedule: $e');
debugPrint('Gagal tambah schedule: $e');
}
}
@ -278,7 +278,7 @@ class ControlNotifier extends Notifier<ControlState> {
} catch (e) {
// Revert ke status jadwal semula jika request API gagal
state = state.copyWith(schedules: oldSchedules, errorMessage: 'Gagal mengubah status jadwal');
print('Gagal toggle schedule: $e');
debugPrint('Gagal toggle schedule: $e');
}
}
@ -296,7 +296,7 @@ class ControlNotifier extends Notifier<ControlState> {
} catch (e) {
// Revert jika gagal menghapus
state = state.copyWith(schedules: oldSchedules, isLoading: false, errorMessage: 'Gagal menghapus jadwal');
print('Gagal hapus schedule: $e');
debugPrint('Gagal hapus schedule: $e');
}
}

View File

@ -69,6 +69,9 @@ class MonitoringState {
/// Agregasi riwayat per jam rata-rata untuk grafik harian.
final List<DailyAverage> hourlyHistory;
/// Status keaktifan perangkat berdasarkan LWT MQTT di database.
final bool isDeviceOnline;
const MonitoringState({
this.latest,
this.isLoading = false,
@ -76,6 +79,7 @@ class MonitoringState {
this.history = const [],
this.dailyHistory = const [],
this.hourlyHistory = const [],
this.isDeviceOnline = false,
});
/// State awal saat provider pertama kali diinisialisasi (status loading aktif).
@ -87,10 +91,6 @@ class MonitoringState {
/// Helper getter untuk mendapatkan nilai kelembapan saat ini. Default: 0 jika null.
double get humidity => latest?.humidity ?? 0;
/// Menentukan status suhu berdasarkan batas maksimum suhu ([maxSuhu]).
/// - Danger: jika suhu melebihi batas maks + 3°C.
/// - Warning: jika suhu melebihi batas maks.
/// - Normal: jika suhu berada di bawah batas maks.
/// Menentukan status suhu berdasarkan batas minimum ([minSuhu]) dan maksimum ([maxSuhu]).
/// - Danger: jika suhu melebihi batas maks + 3°C atau di bawah batas min - 3°C.
/// - Warning: jika suhu melebihi batas maks atau di bawah batas min.
@ -128,6 +128,7 @@ class MonitoringState {
List<ChartData>? history,
List<DailyAverage>? dailyHistory,
List<DailyAverage>? hourlyHistory,
bool? isDeviceOnline,
}) {
return MonitoringState(
latest: latest == _sentinel ? this.latest : latest as SensorModel?,
@ -136,6 +137,7 @@ class MonitoringState {
history: history ?? this.history,
dailyHistory: dailyHistory ?? this.dailyHistory,
hourlyHistory: hourlyHistory ?? this.hourlyHistory,
isDeviceOnline: isDeviceOnline ?? this.isDeviceOnline,
);
}
}
@ -210,11 +212,24 @@ class MonitoringNotifier extends StateNotifier<MonitoringState> {
// Abaikan jika endpoint agregasi belum tersedia atau terjadi error (chart akan kosong)
}
// Ambil status keaktifan perangkat dari database
bool isDeviceOnline = false;
try {
final deviceStatus = await _repo.getDeviceStatus();
isDeviceOnline = deviceStatus['is_online'] as bool? ?? false;
} catch (e) {
if (latest != null) {
final diff = DateTime.now().difference(latest.timestamp);
isDeviceOnline = diff.inMinutes < 5;
}
}
state = state.copyWith(
latest: latest,
history: historyCharts,
dailyHistory: dailyParsed,
hourlyHistory: hourlyParsed,
isDeviceOnline: isDeviceOnline,
isLoading: false,
errorMessage: null,
);
@ -262,10 +277,21 @@ class MonitoringNotifier extends StateNotifier<MonitoringState> {
? updatedHistory.sublist(updatedHistory.length - 200)
: updatedHistory;
// Ambil status keaktifan perangkat dari database
bool isDeviceOnline = false;
try {
final deviceStatus = await _repo.getDeviceStatus();
isDeviceOnline = deviceStatus['is_online'] as bool? ?? false;
} catch (e) {
final diff = DateTime.now().difference(sensor.timestamp);
isDeviceOnline = diff.inMinutes < 5;
}
state = state.copyWith(
latest: sensor,
isLoading: false,
history: trimmed,
isDeviceOnline: isDeviceOnline,
errorMessage: null,
);

View File

@ -53,13 +53,15 @@ class DeviceStatusCard extends StatelessWidget {
color: AppTheme.textSecondary,
),
),
if (!isOnline && lastSeen != null) ...[
if (lastSeen != null) ...[
const SizedBox(height: 2),
Text(
'Terakhir aktif: ${_formatDate(lastSeen!)}',
isOnline
? 'Update: ${_formatTimeOnly(lastSeen!)}'
: 'Terakhir aktif: ${_formatDate(lastSeen!)}',
style: TextStyle(
fontSize: 11,
color: Colors.red.shade400,
color: isOnline ? AppTheme.textSecondary : Colors.red.shade400,
fontWeight: FontWeight.w500,
),
),
@ -80,6 +82,10 @@ class DeviceStatusCard extends StatelessWidget {
);
}
String _formatTimeOnly(DateTime date) {
return '${date.hour.toString().padLeft(2, '0')}:${date.minute.toString().padLeft(2, '0')}:${date.second.toString().padLeft(2, '0')}';
}
String _formatDate(DateTime date) {
final now = DateTime.now();
final diff = now.difference(date);

View File

@ -27,7 +27,7 @@ class StaticKumbungPainter extends CustomPainter {
..style = PaintingStyle.stroke;
final wallPaint = Paint()
..color = Colors.blueGrey.withOpacity(0.15)
..color = Colors.blueGrey.withValues(alpha: 0.15)
..strokeWidth = 1.5
..style = PaintingStyle.stroke;
@ -100,11 +100,11 @@ class StaticKumbungPainter extends CustomPainter {
/// Menggambar balok 3D isometrik untuk rak jamur
void _drawRak(Canvas canvas, _Rak3D rak) {
final fillPaint = Paint()
..color = Colors.teal.withOpacity(0.12)
..color = Colors.teal.withValues(alpha: 0.12)
..style = PaintingStyle.fill;
final borderPaint = Paint()
..color = Colors.teal.withOpacity(0.4)
..color = Colors.teal.withValues(alpha: 0.4)
..strokeWidth = 1.0
..style = PaintingStyle.stroke;
@ -146,7 +146,7 @@ class StaticKumbungPainter extends CustomPainter {
..lineTo(p11H.dx, p11H.dy)
..lineTo(p01H.dx, p01H.dy)
..close();
canvas.drawPath(topFace, fillPaint..color = Colors.teal.withOpacity(0.18));
canvas.drawPath(topFace, fillPaint..color = Colors.teal.withValues(alpha: 0.18));
canvas.drawPath(topFace, borderPaint);
}
@ -202,38 +202,39 @@ class HeatmapKumbungPainter extends CustomPainter {
final double radiusX = radius * transformer.scale * math.cos(30.0 * math.pi / 180.0);
final double radiusY = radius * transformer.scale * math.sin(30.0 * math.pi / 180.0);
// Tentukan warna heatmap berdasarkan nilai aktual sensor
Color centerColor;
// Tentukan warna dasar heatmap (tanpa opacity bawaan agar pencampuran warna gradien lebih terkontrol)
Color baseColor;
if (isSuhuMode) {
// Peta Suhu: Dingin (< 22°C) = Biru, Optimal (22 - 28°C) = Hijau, Panas (> 28°C) = Merah/Oranye
if (value < 22) {
centerColor = Colors.blue.withOpacity(0.5);
} else if (value <= 28) {
centerColor = Colors.green.withOpacity(0.45);
// Peta Suhu: Dingin (< 20°C) = Electric Blue, Optimal (20 - 33°C) = Neon Green, Panas (> 33°C) = Neon Orange ke Red
if (value < 20) {
baseColor = const Color(0xFF2979FF); // Electric Blue
} else if (value <= 33) {
baseColor = const Color(0xFF00E676); // Neon Green
} else {
// Semakin panas semakin merah menyala
final factor = ((value - 28) / 10).clamp(0.0, 1.0);
centerColor = Color.lerp(Colors.orange, Colors.redAccent, factor)!.withOpacity(0.55);
final factor = ((value - 33) / 10).clamp(0.0, 1.0);
baseColor = Color.lerp(const Color(0xFFFFA000), const Color(0xFFFF1744), factor)!;
}
} else {
// Peta Kelembapan: Kering (< 60%) = Kuning/Oranye, Cukup (60 - 80%) = Hijau, Basah/Optimal (> 80%) = Biru/Sian
// Peta Kelembapan: Kering (< 60%) = Kuning/Oranye, Cukup (60 - 80%) = Neon Green, Basah/Optimal (> 80%) = Electric Cyan
if (value < 60) {
centerColor = Colors.orange.withOpacity(0.5);
baseColor = const Color(0xFFFF9100); // Vibrant Orange
} else if (value < 80) {
centerColor = Colors.green.withOpacity(0.45);
baseColor = const Color(0xFF00E676); // Neon Green
} else {
centerColor = Colors.cyan.withOpacity(0.5);
baseColor = const Color(0xFF00E5FF); // Electric Cyan
}
}
final paint = Paint()
..shader = RadialGradient(
colors: [
centerColor,
centerColor.withOpacity(0.15),
baseColor.withValues(alpha: 0.70), // Center: strong glow
baseColor.withValues(alpha: 0.45), // Mid: visible effect area
baseColor.withValues(alpha: 0.15), // Edge: soft fade
Colors.transparent,
],
stops: const [0.0, 0.6, 1.0],
stops: const [0.0, 0.5, 0.85, 1.0],
).createShader(Rect.fromLTRB(
center.dx - radiusX,
center.dy - radiusY,
@ -292,7 +293,7 @@ class PinKumbungPainter extends CustomPainter {
// 2. Menggambar Garis Bantu Vertikal (Dotted/Dashed Line)
final linePaint = Paint()
..color = pinColor.withOpacity(0.5)
..color = pinColor.withValues(alpha: 0.5)
..strokeWidth = 1.2
..style = PaintingStyle.stroke;
@ -303,7 +304,7 @@ class PinKumbungPainter extends CustomPainter {
final double pulseRadius = pulseMaxRadius * animationValue;
final double pulseOpacity = (1.0 - animationValue).clamp(0.0, 1.0);
final pulsePaint = Paint()
..color = pinColor.withOpacity(pulseOpacity * 0.4)
..color = pinColor.withValues(alpha: pulseOpacity * 0.4)
..style = PaintingStyle.fill;
// Proyeksi isometrik elips untuk pulsing
@ -382,7 +383,7 @@ class PinKumbungPainter extends CustomPainter {
// Paint background tag box (semacam pill box gelap)
final bgPaint = Paint()
..color = Colors.black.withOpacity(0.7)
..color = Colors.black.withValues(alpha: 0.7)
..style = PaintingStyle.fill;
canvas.drawRRect(rrect, bgPaint);

View File

@ -8,6 +8,7 @@ class MonitoringCard extends StatelessWidget {
final String subtitle;
final MonitoringStatus status;
final Gradient gradient;
final DateTime? timestamp;
const MonitoringCard({
super.key,
@ -16,6 +17,7 @@ class MonitoringCard extends StatelessWidget {
required this.subtitle,
required this.status,
required this.gradient,
this.timestamp,
});
Color get _statusColor {
@ -40,6 +42,10 @@ class MonitoringCard extends StatelessWidget {
: Icons.water_drop;
}
String _formatTime(DateTime date) {
return '${date.hour.toString().padLeft(2, '0')}:${date.minute.toString().padLeft(2, '0')}:${date.second.toString().padLeft(2, '0')}';
}
@override
Widget build(BuildContext context) {
return Container(
@ -156,16 +162,30 @@ class MonitoringCard extends StatelessWidget {
],
),
const Spacer(),
SizedBox(
height: 34,
child: Align(
Align(
alignment: Alignment.bottomLeft,
child: Text(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
subtitle,
maxLines: 2,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(color: Colors.white70, fontSize: 12),
),
if (timestamp != null) ...[
const SizedBox(height: 2),
Text(
'Update: ${_formatTime(timestamp!)}',
style: TextStyle(
color: Colors.white.withValues(alpha: 0.65),
fontSize: 9.5,
fontWeight: FontWeight.w500,
),
),
],
],
),
),
],

View File

@ -38,7 +38,7 @@ class ScheduleSection extends StatelessWidget {
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: schedules.length,
separatorBuilder: (_, __) => Divider(
separatorBuilder: (context, index) => Divider(
height: 1,
color: Colors.grey.shade200,
indent: 16,

View File

@ -16,8 +16,8 @@ class SensorLimitCard extends StatefulWidget {
});
// Nilai default untuk tanaman jamur
static const double defaultSuhuMin = 22.0;
static const double defaultSuhuMax = 28.0;
static const double defaultSuhuMin = 20.0;
static const double defaultSuhuMax = 33.0;
static const double defaultKelembapan = 90.0;
@override
@ -33,9 +33,9 @@ class _SensorLimitCardState extends State<SensorLimitCard> {
@override
void initState() {
super.initState();
_draftSuhuMin = widget.suhuMinLimit;
_draftSuhuMax = widget.suhuMaxLimit;
_draftKelembapan = widget.kelembapanLimit;
_draftSuhuMin = widget.suhuMinLimit.clamp(15.0, 50.0);
_draftSuhuMax = widget.suhuMaxLimit.clamp(15.0, 50.0);
_draftKelembapan = widget.kelembapanLimit.clamp(50.0, 100.0);
}
// Sinkronisasi draft jika nilai dari provider berubah dari luar
@ -51,9 +51,9 @@ class _SensorLimitCardState extends State<SensorLimitCard> {
// Update draft hanya jika user belum mengedit
if (serverChanged && draftUnchanged) {
_draftSuhuMin = widget.suhuMinLimit;
_draftSuhuMax = widget.suhuMaxLimit;
_draftKelembapan = widget.kelembapanLimit;
_draftSuhuMin = widget.suhuMinLimit.clamp(15.0, 50.0);
_draftSuhuMax = widget.suhuMaxLimit.clamp(15.0, 50.0);
_draftKelembapan = widget.kelembapanLimit.clamp(50.0, 100.0);
}
}

View File

@ -40,4 +40,12 @@ class IotRepository {
}
return Future.value([]);
}
/// Mengambil status keaktifan perangkat (is_online, last_seen) dari database.
Future<Map<String, dynamic>> getDeviceStatus() {
if (_service is ApiIotService) {
return _service.fetchDeviceStatus();
}
return Future.value({'is_online': false, 'last_seen': null});
}
}

View File

@ -118,4 +118,18 @@ class ApiIotService implements IotService {
throw Exception('Error fetching hourly history: $e');
}
}
/// Mengambil status keaktifan perangkat (is_online, last_seen) dari backend.
Future<Map<String, dynamic>> fetchDeviceStatus() async {
try {
final response = await _dio.get('${ApiConfig.baseUrl}/device/status/$deviceId');
if (response.statusCode == 200) {
return response.data as Map<String, dynamic>;
} else {
throw Exception('Failed to fetch device status: ${response.statusCode}');
}
} catch (e) {
throw Exception('Error fetching device status: $e');
}
}
}

View File

@ -1,6 +1,7 @@
import 'package:supabase_flutter/supabase_flutter.dart' as supabase;
import 'package:dio/dio.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:flutter/foundation.dart';
import '../models/user_model.dart';
import '../core/api_config.dart';
import 'auth_service.dart';
@ -33,7 +34,7 @@ class SupabaseAuthService implements AuthService {
deviceId = deviceRes.data['device_id'];
}
} catch (e) {
print('Gagal mengambil info device: $e');
debugPrint('Gagal mengambil info device: $e');
// Tidak melempar error agar login tetap sukses meskipun user belum menghubungkan device
}
@ -148,7 +149,7 @@ class SupabaseAuthService implements AuthService {
final prefs = await SharedPreferences.getInstance();
deviceId = prefs.getString('cached_device_id');
} catch (e) {
print('Gagal membaca cache device_id: $e');
debugPrint('Gagal membaca cache device_id: $e');
}
return UserModel(