upload projek
|
|
@ -91,3 +91,8 @@ key.properties
|
||||||
.env.*
|
.env.*
|
||||||
lib/core/env.dart
|
lib/core/env.dart
|
||||||
|
|
||||||
|
# Markdown files
|
||||||
|
*.md
|
||||||
|
!README.md
|
||||||
|
**/*.md
|
||||||
|
!**/README.md
|
||||||
|
|
|
||||||
|
Before Width: | Height: | Size: 1.5 MiB After Width: | Height: | Size: 453 KiB |
|
Before Width: | Height: | Size: 737 KiB After Width: | Height: | Size: 226 KiB |
|
Before Width: | Height: | Size: 1.5 MiB After Width: | Height: | Size: 453 KiB |
|
Before Width: | Height: | Size: 737 KiB After Width: | Height: | Size: 226 KiB |
|
Before Width: | Height: | Size: 2.5 MiB After Width: | Height: | Size: 731 KiB |
|
Before Width: | Height: | Size: 4.8 MiB After Width: | Height: | Size: 1.3 MiB |
|
Before Width: | Height: | Size: 7.4 MiB After Width: | Height: | Size: 2.1 MiB |
|
Before Width: | Height: | Size: 2.5 MiB After Width: | Height: | Size: 731 KiB |
|
Before Width: | Height: | Size: 4.8 MiB After Width: | Height: | Size: 1.3 MiB |
|
Before Width: | Height: | Size: 7.4 MiB After Width: | Height: | Size: 2.1 MiB |
|
Before Width: | Height: | Size: 5.4 KiB After Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 2.6 KiB After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 8.1 KiB After Width: | Height: | Size: 2.9 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 5.3 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 8.4 KiB |
|
Before Width: | Height: | Size: 2.4 MiB After Width: | Height: | Size: 490 KiB |
|
Before Width: | Height: | Size: 9.1 MiB After Width: | Height: | Size: 1.8 MiB |
|
|
@ -4,7 +4,7 @@
|
||||||
|
|
||||||
## Paragraf 1 — Gambaran Arsitektur Sistem
|
## 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.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -74,7 +74,7 @@ Berikut adalah penjelasan mengenai aktor-aktor yang terlibat di dalam sistem JTA
|
||||||
| No | Nama Aktor | Jenis | Deskripsi |
|
| 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. |
|
| 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. |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -35,8 +35,8 @@ class AppRoutes {
|
||||||
static PageRouteBuilder<dynamic> _fadeRoute(Widget page) {
|
static PageRouteBuilder<dynamic> _fadeRoute(Widget page) {
|
||||||
return PageRouteBuilder(
|
return PageRouteBuilder(
|
||||||
transitionDuration: const Duration(milliseconds: 400),
|
transitionDuration: const Duration(milliseconds: 400),
|
||||||
pageBuilder: (_, __, ___) => page,
|
pageBuilder: (context, state, childState) => page,
|
||||||
transitionsBuilder: (_, animation, __, child) {
|
transitionsBuilder: (context, animation, secondaryAnimation, child) {
|
||||||
return FadeTransition(
|
return FadeTransition(
|
||||||
opacity: CurvedAnimation(parent: animation, curve: Curves.easeInOut),
|
opacity: CurvedAnimation(parent: animation, curve: Curves.easeInOut),
|
||||||
child: child,
|
child: child,
|
||||||
|
|
|
||||||
|
|
@ -159,13 +159,7 @@ class ControlPage extends ConsumerWidget {
|
||||||
}
|
}
|
||||||
|
|
||||||
bool _checkIsOnline(WidgetRef ref) {
|
bool _checkIsOnline(WidgetRef ref) {
|
||||||
final monitoringState = ref.watch(monitoringProvider);
|
return ref.watch(monitoringProvider).isDeviceOnline;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -225,7 +219,7 @@ class ControlPage extends ConsumerWidget {
|
||||||
borderRadius: BorderRadius.circular(24),
|
borderRadius: BorderRadius.circular(24),
|
||||||
boxShadow: [
|
boxShadow: [
|
||||||
BoxShadow(
|
BoxShadow(
|
||||||
color: Colors.black.withOpacity(0.04),
|
color: Colors.black.withValues(alpha: 0.04),
|
||||||
blurRadius: 16,
|
blurRadius: 16,
|
||||||
offset: const Offset(0, 4),
|
offset: const Offset(0, 4),
|
||||||
),
|
),
|
||||||
|
|
@ -309,6 +303,7 @@ class ControlPage extends ConsumerWidget {
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
const SizedBox(height: 20),
|
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),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -167,7 +167,7 @@ class MonitoringPage extends ConsumerWidget {
|
||||||
),
|
),
|
||||||
] else ...[
|
] else ...[
|
||||||
DeviceStatusCard(
|
DeviceStatusCard(
|
||||||
isOnline: monitoring.latest != null && DateTime.now().difference(monitoring.latest!.timestamp).inMinutes < 5,
|
isOnline: monitoring.isDeviceOnline,
|
||||||
deviceCode: _getDeviceCode(ref),
|
deviceCode: _getDeviceCode(ref),
|
||||||
lastSeen: monitoring.latest?.timestamp,
|
lastSeen: monitoring.latest?.timestamp,
|
||||||
),
|
),
|
||||||
|
|
@ -195,6 +195,7 @@ class MonitoringPage extends ConsumerWidget {
|
||||||
subtitle: 'Suhu Real Time',
|
subtitle: 'Suhu Real Time',
|
||||||
status: monitoring.tempStatus(control.suhuMinLimit, control.suhuMaxLimit),
|
status: monitoring.tempStatus(control.suhuMinLimit, control.suhuMaxLimit),
|
||||||
gradient: AppTheme.suhuGradient,
|
gradient: AppTheme.suhuGradient,
|
||||||
|
timestamp: monitoring.latest?.timestamp,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 16),
|
const SizedBox(width: 16),
|
||||||
|
|
@ -205,6 +206,7 @@ class MonitoringPage extends ConsumerWidget {
|
||||||
subtitle: 'Kelembapan Real Time',
|
subtitle: 'Kelembapan Real Time',
|
||||||
status: monitoring.humidStatus(control.kelembapanLimit),
|
status: monitoring.humidStatus(control.kelembapanLimit),
|
||||||
gradient: AppTheme.humidGradient,
|
gradient: AppTheme.humidGradient,
|
||||||
|
timestamp: monitoring.latest?.timestamp,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import 'dart:math' as math;
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
@ -22,7 +23,7 @@ class _KumbungMapPageState extends ConsumerState<KumbungMapPage>
|
||||||
double _sensorX = 4.0;
|
double _sensorX = 4.0;
|
||||||
double _sensorY = 3.0;
|
double _sensorY = 3.0;
|
||||||
final double _sensorZ = 1.2; // Tinggi melayang sensor di rak
|
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
|
// Mode Heatmap: true = Suhu, false = Kelembapan
|
||||||
bool _isSuhuMode = true;
|
bool _isSuhuMode = true;
|
||||||
|
|
@ -48,9 +49,8 @@ class _KumbungMapPageState extends ConsumerState<KumbungMapPage>
|
||||||
Future<void> _loadSensorSettings() async {
|
Future<void> _loadSensorSettings() async {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
setState(() {
|
setState(() {
|
||||||
_sensorX = prefs.getDouble('sensor_x') ?? 4.0;
|
_sensorX = (prefs.getDouble('sensor_x') ?? 4.0).clamp(0.5, 7.5);
|
||||||
_sensorY = prefs.getDouble('sensor_y') ?? 3.0;
|
_sensorY = (prefs.getDouble('sensor_y') ?? 3.0).clamp(0.5, 5.5);
|
||||||
_sensorRadius = prefs.getDouble('sensor_radius') ?? 3.0;
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -59,16 +59,16 @@ class _KumbungMapPageState extends ConsumerState<KumbungMapPage>
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
await prefs.setDouble('sensor_x', _sensorX);
|
await prefs.setDouble('sensor_x', _sensorX);
|
||||||
await prefs.setDouble('sensor_y', _sensorY);
|
await prefs.setDouble('sensor_y', _sensorY);
|
||||||
await prefs.setDouble('sensor_radius', _sensorRadius);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mengubah posisi sensor saat canvas diketuk oleh pengguna
|
// Mengubah posisi sensor saat canvas diketuk oleh pengguna
|
||||||
void _handleCanvasTap(Offset localPosition, Size canvasSize, IsometricTransform transformer) {
|
void _handleCanvasTap(Offset localPosition, Size canvasSize, IsometricTransform transformer) {
|
||||||
final worldPt = transformer.toWorldGround(localPosition);
|
final worldPt = transformer.toWorldGround(localPosition);
|
||||||
|
|
||||||
// Batasi agar koordinat berada di dalam rentang ruangan (8m x 6m)
|
// Batasi agar koordinat berada di dalam rentang slider (0.5 s.d 7.5 untuk X, 0.5 s.d 5.5 untuk Y)
|
||||||
final double clampedX = worldPt.dx.clamp(0.0, 8.0);
|
// guna menghindari crash assertion Slider jika user mengetuk area luar grid lantai.
|
||||||
final double clampedY = worldPt.dy.clamp(0.0, 6.0);
|
final double clampedX = worldPt.dx.clamp(0.5, 7.5);
|
||||||
|
final double clampedY = worldPt.dy.clamp(0.5, 5.5);
|
||||||
|
|
||||||
setState(() {
|
setState(() {
|
||||||
_sensorX = clampedX;
|
_sensorX = clampedX;
|
||||||
|
|
@ -173,14 +173,27 @@ class _KumbungMapPageState extends ConsumerState<KumbungMapPage>
|
||||||
// Canvas Utama Rendering Isometrik
|
// Canvas Utama Rendering Isometrik
|
||||||
LayoutBuilder(
|
LayoutBuilder(
|
||||||
builder: (context, constraints) {
|
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(
|
final centerOrigin = Offset(
|
||||||
constraints.maxWidth / 2,
|
constraints.maxWidth / 2 - dxCenterOffset,
|
||||||
constraints.maxHeight / 2 + 30,
|
constraints.maxHeight / 2 - dyCenterOffset,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Skala dinamis: 1 meter = 24 piksel
|
// Skala dinamis: 1 meter = 24 piksel
|
||||||
final transformer = IsometricTransform(
|
final transformer = IsometricTransform(
|
||||||
scale: 24.0,
|
scale: scale,
|
||||||
origin: centerOrigin,
|
origin: centerOrigin,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -197,9 +210,9 @@ class _KumbungMapPageState extends ConsumerState<KumbungMapPage>
|
||||||
size: Size(constraints.maxWidth, constraints.maxHeight),
|
size: Size(constraints.maxWidth, constraints.maxHeight),
|
||||||
painter: StaticKumbungPainter(
|
painter: StaticKumbungPainter(
|
||||||
transformer: transformer,
|
transformer: transformer,
|
||||||
roomWidth: 6.0,
|
roomWidth: roomWidth,
|
||||||
roomLength: 8.0,
|
roomLength: roomLength,
|
||||||
roomHeight: 2.5,
|
roomHeight: roomHeight,
|
||||||
),
|
),
|
||||||
foregroundPainter: PinKumbungPainter(
|
foregroundPainter: PinKumbungPainter(
|
||||||
transformer: transformer,
|
transformer: transformer,
|
||||||
|
|
@ -280,76 +293,6 @@ class _KumbungMapPageState extends ConsumerState<KumbungMapPage>
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
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 ───────────────────
|
// ── Dashboard Referensi Budidaya ───────────────────
|
||||||
_buildReferenceCard(),
|
_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 Kartu Referensi Budidaya Jamur
|
||||||
Widget _buildReferenceCard() {
|
Widget _buildReferenceCard() {
|
||||||
|
|
@ -452,7 +342,7 @@ class _KumbungMapPageState extends ConsumerState<KumbungMapPage>
|
||||||
borderRadius: BorderRadius.circular(20),
|
borderRadius: BorderRadius.circular(20),
|
||||||
boxShadow: [
|
boxShadow: [
|
||||||
BoxShadow(
|
BoxShadow(
|
||||||
color: Colors.black.withOpacity(0.02),
|
color: Colors.black.withValues(alpha: 0.02),
|
||||||
blurRadius: 10,
|
blurRadius: 10,
|
||||||
offset: const Offset(0, 2),
|
offset: const Offset(0, 2),
|
||||||
),
|
),
|
||||||
|
|
@ -466,7 +356,7 @@ class _KumbungMapPageState extends ConsumerState<KumbungMapPage>
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.all(6),
|
padding: const EdgeInsets.all(6),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: AppTheme.primary.withOpacity(0.1),
|
color: AppTheme.primary.withValues(alpha: 0.1),
|
||||||
shape: BoxShape.circle,
|
shape: BoxShape.circle,
|
||||||
),
|
),
|
||||||
child: const Icon(Icons.menu_book, color: AppTheme.primary, size: 16),
|
child: const Icon(Icons.menu_book, color: AppTheme.primary, size: 16),
|
||||||
|
|
@ -495,7 +385,7 @@ class _KumbungMapPageState extends ConsumerState<KumbungMapPage>
|
||||||
icon: Icons.thermostat,
|
icon: Icons.thermostat,
|
||||||
iconColor: Colors.teal,
|
iconColor: Colors.teal,
|
||||||
label: 'Suhu Ideal',
|
label: 'Suhu Ideal',
|
||||||
value: '22°C - 28°C',
|
value: '20°C - 33°C',
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
|
|
@ -515,14 +405,14 @@ class _KumbungMapPageState extends ConsumerState<KumbungMapPage>
|
||||||
const Row(
|
const Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Icon(Icons.wb_sunny_outlined, size: 16, color: Colors.orange),
|
Icon(Icons.info_outline, size: 16, color: AppTheme.primary),
|
||||||
SizedBox(width: 8),
|
SizedBox(width: 8),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
'Waktu Penyiraman Terbaik:',
|
'Informasi Suhu & Kelembapan:',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
|
|
@ -531,7 +421,7 @@ class _KumbungMapPageState extends ConsumerState<KumbungMapPage>
|
||||||
),
|
),
|
||||||
SizedBox(height: 4),
|
SizedBox(height: 4),
|
||||||
Text(
|
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),
|
style: TextStyle(fontSize: 11, color: AppTheme.textSecondary, height: 1.5),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
@ -553,9 +443,9 @@ class _KumbungMapPageState extends ConsumerState<KumbungMapPage>
|
||||||
return Container(
|
return Container(
|
||||||
padding: const EdgeInsets.all(10),
|
padding: const EdgeInsets.all(10),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: iconColor.withOpacity(0.05),
|
color: iconColor.withValues(alpha: 0.05),
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
border: Border.all(color: iconColor.withOpacity(0.1)),
|
border: Border.all(color: iconColor.withValues(alpha: 0.1)),
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
|
|
@ -574,7 +464,7 @@ class _KumbungMapPageState extends ConsumerState<KumbungMapPage>
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
color: iconColor.withOpacity(0.9),
|
color: iconColor.withValues(alpha: 0.9),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -74,7 +74,7 @@ class _MainPageState extends ConsumerState<MainPage> {
|
||||||
BottomNavigationBarItem(
|
BottomNavigationBarItem(
|
||||||
icon: Icon(Icons.map_outlined),
|
icon: Icon(Icons.map_outlined),
|
||||||
activeIcon: Icon(Icons.map),
|
activeIcon: Icon(Icons.map),
|
||||||
label: 'Peta',
|
label: 'Heat Map',
|
||||||
),
|
),
|
||||||
BottomNavigationBarItem(
|
BottomNavigationBarItem(
|
||||||
icon: Icon(Icons.tune_outlined),
|
icon: Icon(Icons.tune_outlined),
|
||||||
|
|
|
||||||
|
|
@ -113,7 +113,7 @@ class ControlNotifier extends Notifier<ControlState> {
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
errorMessage: 'Gagal memuat data awal. Periksa koneksi internet.',
|
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
|
// 3. Batalkan/Revert status tombol jika request API mengalami kegagalan
|
||||||
_syncLockedUntil = null;
|
_syncLockedUntil = null;
|
||||||
state = state.copyWith(isPumpOn: !willTurnOn, errorMessage: 'Gagal mengontrol pompa');
|
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,
|
kelembapanLimit: oldKelembapan,
|
||||||
errorMessage: 'Gagal menyimpan batasan sensor',
|
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) {
|
} catch (e) {
|
||||||
state = state.copyWith(isLoading: false, errorMessage: 'Gagal menambah jadwal');
|
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) {
|
} catch (e) {
|
||||||
// Revert ke status jadwal semula jika request API gagal
|
// Revert ke status jadwal semula jika request API gagal
|
||||||
state = state.copyWith(schedules: oldSchedules, errorMessage: 'Gagal mengubah status jadwal');
|
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) {
|
} catch (e) {
|
||||||
// Revert jika gagal menghapus
|
// Revert jika gagal menghapus
|
||||||
state = state.copyWith(schedules: oldSchedules, isLoading: false, errorMessage: 'Gagal menghapus jadwal');
|
state = state.copyWith(schedules: oldSchedules, isLoading: false, errorMessage: 'Gagal menghapus jadwal');
|
||||||
print('Gagal hapus schedule: $e');
|
debugPrint('Gagal hapus schedule: $e');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -69,6 +69,9 @@ class MonitoringState {
|
||||||
/// Agregasi riwayat per jam rata-rata untuk grafik harian.
|
/// Agregasi riwayat per jam rata-rata untuk grafik harian.
|
||||||
final List<DailyAverage> hourlyHistory;
|
final List<DailyAverage> hourlyHistory;
|
||||||
|
|
||||||
|
/// Status keaktifan perangkat berdasarkan LWT MQTT di database.
|
||||||
|
final bool isDeviceOnline;
|
||||||
|
|
||||||
const MonitoringState({
|
const MonitoringState({
|
||||||
this.latest,
|
this.latest,
|
||||||
this.isLoading = false,
|
this.isLoading = false,
|
||||||
|
|
@ -76,6 +79,7 @@ class MonitoringState {
|
||||||
this.history = const [],
|
this.history = const [],
|
||||||
this.dailyHistory = const [],
|
this.dailyHistory = const [],
|
||||||
this.hourlyHistory = const [],
|
this.hourlyHistory = const [],
|
||||||
|
this.isDeviceOnline = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
/// State awal saat provider pertama kali diinisialisasi (status loading aktif).
|
/// 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.
|
/// Helper getter untuk mendapatkan nilai kelembapan saat ini. Default: 0 jika null.
|
||||||
double get humidity => latest?.humidity ?? 0;
|
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]).
|
/// 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.
|
/// - 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.
|
/// - Warning: jika suhu melebihi batas maks atau di bawah batas min.
|
||||||
|
|
@ -128,6 +128,7 @@ class MonitoringState {
|
||||||
List<ChartData>? history,
|
List<ChartData>? history,
|
||||||
List<DailyAverage>? dailyHistory,
|
List<DailyAverage>? dailyHistory,
|
||||||
List<DailyAverage>? hourlyHistory,
|
List<DailyAverage>? hourlyHistory,
|
||||||
|
bool? isDeviceOnline,
|
||||||
}) {
|
}) {
|
||||||
return MonitoringState(
|
return MonitoringState(
|
||||||
latest: latest == _sentinel ? this.latest : latest as SensorModel?,
|
latest: latest == _sentinel ? this.latest : latest as SensorModel?,
|
||||||
|
|
@ -136,6 +137,7 @@ class MonitoringState {
|
||||||
history: history ?? this.history,
|
history: history ?? this.history,
|
||||||
dailyHistory: dailyHistory ?? this.dailyHistory,
|
dailyHistory: dailyHistory ?? this.dailyHistory,
|
||||||
hourlyHistory: hourlyHistory ?? this.hourlyHistory,
|
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)
|
// 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(
|
state = state.copyWith(
|
||||||
latest: latest,
|
latest: latest,
|
||||||
history: historyCharts,
|
history: historyCharts,
|
||||||
dailyHistory: dailyParsed,
|
dailyHistory: dailyParsed,
|
||||||
hourlyHistory: hourlyParsed,
|
hourlyHistory: hourlyParsed,
|
||||||
|
isDeviceOnline: isDeviceOnline,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
errorMessage: null,
|
errorMessage: null,
|
||||||
);
|
);
|
||||||
|
|
@ -262,10 +277,21 @@ class MonitoringNotifier extends StateNotifier<MonitoringState> {
|
||||||
? updatedHistory.sublist(updatedHistory.length - 200)
|
? updatedHistory.sublist(updatedHistory.length - 200)
|
||||||
: updatedHistory;
|
: 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(
|
state = state.copyWith(
|
||||||
latest: sensor,
|
latest: sensor,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
history: trimmed,
|
history: trimmed,
|
||||||
|
isDeviceOnline: isDeviceOnline,
|
||||||
errorMessage: null,
|
errorMessage: null,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -53,13 +53,15 @@ class DeviceStatusCard extends StatelessWidget {
|
||||||
color: AppTheme.textSecondary,
|
color: AppTheme.textSecondary,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (!isOnline && lastSeen != null) ...[
|
if (lastSeen != null) ...[
|
||||||
const SizedBox(height: 2),
|
const SizedBox(height: 2),
|
||||||
Text(
|
Text(
|
||||||
'Terakhir aktif: ${_formatDate(lastSeen!)}',
|
isOnline
|
||||||
|
? 'Update: ${_formatTimeOnly(lastSeen!)}'
|
||||||
|
: 'Terakhir aktif: ${_formatDate(lastSeen!)}',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
color: Colors.red.shade400,
|
color: isOnline ? AppTheme.textSecondary : Colors.red.shade400,
|
||||||
fontWeight: FontWeight.w500,
|
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) {
|
String _formatDate(DateTime date) {
|
||||||
final now = DateTime.now();
|
final now = DateTime.now();
|
||||||
final diff = now.difference(date);
|
final diff = now.difference(date);
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,7 @@ class StaticKumbungPainter extends CustomPainter {
|
||||||
..style = PaintingStyle.stroke;
|
..style = PaintingStyle.stroke;
|
||||||
|
|
||||||
final wallPaint = Paint()
|
final wallPaint = Paint()
|
||||||
..color = Colors.blueGrey.withOpacity(0.15)
|
..color = Colors.blueGrey.withValues(alpha: 0.15)
|
||||||
..strokeWidth = 1.5
|
..strokeWidth = 1.5
|
||||||
..style = PaintingStyle.stroke;
|
..style = PaintingStyle.stroke;
|
||||||
|
|
||||||
|
|
@ -100,11 +100,11 @@ class StaticKumbungPainter extends CustomPainter {
|
||||||
/// Menggambar balok 3D isometrik untuk rak jamur
|
/// Menggambar balok 3D isometrik untuk rak jamur
|
||||||
void _drawRak(Canvas canvas, _Rak3D rak) {
|
void _drawRak(Canvas canvas, _Rak3D rak) {
|
||||||
final fillPaint = Paint()
|
final fillPaint = Paint()
|
||||||
..color = Colors.teal.withOpacity(0.12)
|
..color = Colors.teal.withValues(alpha: 0.12)
|
||||||
..style = PaintingStyle.fill;
|
..style = PaintingStyle.fill;
|
||||||
|
|
||||||
final borderPaint = Paint()
|
final borderPaint = Paint()
|
||||||
..color = Colors.teal.withOpacity(0.4)
|
..color = Colors.teal.withValues(alpha: 0.4)
|
||||||
..strokeWidth = 1.0
|
..strokeWidth = 1.0
|
||||||
..style = PaintingStyle.stroke;
|
..style = PaintingStyle.stroke;
|
||||||
|
|
||||||
|
|
@ -146,7 +146,7 @@ class StaticKumbungPainter extends CustomPainter {
|
||||||
..lineTo(p11H.dx, p11H.dy)
|
..lineTo(p11H.dx, p11H.dy)
|
||||||
..lineTo(p01H.dx, p01H.dy)
|
..lineTo(p01H.dx, p01H.dy)
|
||||||
..close();
|
..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);
|
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 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);
|
final double radiusY = radius * transformer.scale * math.sin(30.0 * math.pi / 180.0);
|
||||||
|
|
||||||
// Tentukan warna heatmap berdasarkan nilai aktual sensor
|
// Tentukan warna dasar heatmap (tanpa opacity bawaan agar pencampuran warna gradien lebih terkontrol)
|
||||||
Color centerColor;
|
Color baseColor;
|
||||||
if (isSuhuMode) {
|
if (isSuhuMode) {
|
||||||
// Peta Suhu: Dingin (< 22°C) = Biru, Optimal (22 - 28°C) = Hijau, Panas (> 28°C) = Merah/Oranye
|
// Peta Suhu: Dingin (< 20°C) = Electric Blue, Optimal (20 - 33°C) = Neon Green, Panas (> 33°C) = Neon Orange ke Red
|
||||||
if (value < 22) {
|
if (value < 20) {
|
||||||
centerColor = Colors.blue.withOpacity(0.5);
|
baseColor = const Color(0xFF2979FF); // Electric Blue
|
||||||
} else if (value <= 28) {
|
} else if (value <= 33) {
|
||||||
centerColor = Colors.green.withOpacity(0.45);
|
baseColor = const Color(0xFF00E676); // Neon Green
|
||||||
} else {
|
} else {
|
||||||
// Semakin panas semakin merah menyala
|
// Semakin panas semakin merah menyala
|
||||||
final factor = ((value - 28) / 10).clamp(0.0, 1.0);
|
final factor = ((value - 33) / 10).clamp(0.0, 1.0);
|
||||||
centerColor = Color.lerp(Colors.orange, Colors.redAccent, factor)!.withOpacity(0.55);
|
baseColor = Color.lerp(const Color(0xFFFFA000), const Color(0xFFFF1744), factor)!;
|
||||||
}
|
}
|
||||||
} else {
|
} 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) {
|
if (value < 60) {
|
||||||
centerColor = Colors.orange.withOpacity(0.5);
|
baseColor = const Color(0xFFFF9100); // Vibrant Orange
|
||||||
} else if (value < 80) {
|
} else if (value < 80) {
|
||||||
centerColor = Colors.green.withOpacity(0.45);
|
baseColor = const Color(0xFF00E676); // Neon Green
|
||||||
} else {
|
} else {
|
||||||
centerColor = Colors.cyan.withOpacity(0.5);
|
baseColor = const Color(0xFF00E5FF); // Electric Cyan
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final paint = Paint()
|
final paint = Paint()
|
||||||
..shader = RadialGradient(
|
..shader = RadialGradient(
|
||||||
colors: [
|
colors: [
|
||||||
centerColor,
|
baseColor.withValues(alpha: 0.70), // Center: strong glow
|
||||||
centerColor.withOpacity(0.15),
|
baseColor.withValues(alpha: 0.45), // Mid: visible effect area
|
||||||
|
baseColor.withValues(alpha: 0.15), // Edge: soft fade
|
||||||
Colors.transparent,
|
Colors.transparent,
|
||||||
],
|
],
|
||||||
stops: const [0.0, 0.6, 1.0],
|
stops: const [0.0, 0.5, 0.85, 1.0],
|
||||||
).createShader(Rect.fromLTRB(
|
).createShader(Rect.fromLTRB(
|
||||||
center.dx - radiusX,
|
center.dx - radiusX,
|
||||||
center.dy - radiusY,
|
center.dy - radiusY,
|
||||||
|
|
@ -292,7 +293,7 @@ class PinKumbungPainter extends CustomPainter {
|
||||||
|
|
||||||
// 2. Menggambar Garis Bantu Vertikal (Dotted/Dashed Line)
|
// 2. Menggambar Garis Bantu Vertikal (Dotted/Dashed Line)
|
||||||
final linePaint = Paint()
|
final linePaint = Paint()
|
||||||
..color = pinColor.withOpacity(0.5)
|
..color = pinColor.withValues(alpha: 0.5)
|
||||||
..strokeWidth = 1.2
|
..strokeWidth = 1.2
|
||||||
..style = PaintingStyle.stroke;
|
..style = PaintingStyle.stroke;
|
||||||
|
|
||||||
|
|
@ -303,7 +304,7 @@ class PinKumbungPainter extends CustomPainter {
|
||||||
final double pulseRadius = pulseMaxRadius * animationValue;
|
final double pulseRadius = pulseMaxRadius * animationValue;
|
||||||
final double pulseOpacity = (1.0 - animationValue).clamp(0.0, 1.0);
|
final double pulseOpacity = (1.0 - animationValue).clamp(0.0, 1.0);
|
||||||
final pulsePaint = Paint()
|
final pulsePaint = Paint()
|
||||||
..color = pinColor.withOpacity(pulseOpacity * 0.4)
|
..color = pinColor.withValues(alpha: pulseOpacity * 0.4)
|
||||||
..style = PaintingStyle.fill;
|
..style = PaintingStyle.fill;
|
||||||
|
|
||||||
// Proyeksi isometrik elips untuk pulsing
|
// Proyeksi isometrik elips untuk pulsing
|
||||||
|
|
@ -382,7 +383,7 @@ class PinKumbungPainter extends CustomPainter {
|
||||||
|
|
||||||
// Paint background tag box (semacam pill box gelap)
|
// Paint background tag box (semacam pill box gelap)
|
||||||
final bgPaint = Paint()
|
final bgPaint = Paint()
|
||||||
..color = Colors.black.withOpacity(0.7)
|
..color = Colors.black.withValues(alpha: 0.7)
|
||||||
..style = PaintingStyle.fill;
|
..style = PaintingStyle.fill;
|
||||||
canvas.drawRRect(rrect, bgPaint);
|
canvas.drawRRect(rrect, bgPaint);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ class MonitoringCard extends StatelessWidget {
|
||||||
final String subtitle;
|
final String subtitle;
|
||||||
final MonitoringStatus status;
|
final MonitoringStatus status;
|
||||||
final Gradient gradient;
|
final Gradient gradient;
|
||||||
|
final DateTime? timestamp;
|
||||||
|
|
||||||
const MonitoringCard({
|
const MonitoringCard({
|
||||||
super.key,
|
super.key,
|
||||||
|
|
@ -16,6 +17,7 @@ class MonitoringCard extends StatelessWidget {
|
||||||
required this.subtitle,
|
required this.subtitle,
|
||||||
required this.status,
|
required this.status,
|
||||||
required this.gradient,
|
required this.gradient,
|
||||||
|
this.timestamp,
|
||||||
});
|
});
|
||||||
|
|
||||||
Color get _statusColor {
|
Color get _statusColor {
|
||||||
|
|
@ -40,6 +42,10 @@ class MonitoringCard extends StatelessWidget {
|
||||||
: Icons.water_drop;
|
: 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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Container(
|
return Container(
|
||||||
|
|
@ -156,16 +162,30 @@ class MonitoringCard extends StatelessWidget {
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const Spacer(),
|
const Spacer(),
|
||||||
SizedBox(
|
Align(
|
||||||
height: 34,
|
alignment: Alignment.bottomLeft,
|
||||||
child: Align(
|
child: Column(
|
||||||
alignment: Alignment.bottomLeft,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
child: Text(
|
mainAxisSize: MainAxisSize.min,
|
||||||
subtitle,
|
children: [
|
||||||
maxLines: 2,
|
Text(
|
||||||
overflow: TextOverflow.ellipsis,
|
subtitle,
|
||||||
style: const TextStyle(color: Colors.white70, fontSize: 12),
|
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -38,7 +38,7 @@ class ScheduleSection extends StatelessWidget {
|
||||||
shrinkWrap: true,
|
shrinkWrap: true,
|
||||||
physics: const NeverScrollableScrollPhysics(),
|
physics: const NeverScrollableScrollPhysics(),
|
||||||
itemCount: schedules.length,
|
itemCount: schedules.length,
|
||||||
separatorBuilder: (_, __) => Divider(
|
separatorBuilder: (context, index) => Divider(
|
||||||
height: 1,
|
height: 1,
|
||||||
color: Colors.grey.shade200,
|
color: Colors.grey.shade200,
|
||||||
indent: 16,
|
indent: 16,
|
||||||
|
|
|
||||||
|
|
@ -16,8 +16,8 @@ class SensorLimitCard extends StatefulWidget {
|
||||||
});
|
});
|
||||||
|
|
||||||
// Nilai default untuk tanaman jamur
|
// Nilai default untuk tanaman jamur
|
||||||
static const double defaultSuhuMin = 22.0;
|
static const double defaultSuhuMin = 20.0;
|
||||||
static const double defaultSuhuMax = 28.0;
|
static const double defaultSuhuMax = 33.0;
|
||||||
static const double defaultKelembapan = 90.0;
|
static const double defaultKelembapan = 90.0;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -33,9 +33,9 @@ class _SensorLimitCardState extends State<SensorLimitCard> {
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_draftSuhuMin = widget.suhuMinLimit;
|
_draftSuhuMin = widget.suhuMinLimit.clamp(15.0, 50.0);
|
||||||
_draftSuhuMax = widget.suhuMaxLimit;
|
_draftSuhuMax = widget.suhuMaxLimit.clamp(15.0, 50.0);
|
||||||
_draftKelembapan = widget.kelembapanLimit;
|
_draftKelembapan = widget.kelembapanLimit.clamp(50.0, 100.0);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sinkronisasi draft jika nilai dari provider berubah dari luar
|
// 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
|
// Update draft hanya jika user belum mengedit
|
||||||
if (serverChanged && draftUnchanged) {
|
if (serverChanged && draftUnchanged) {
|
||||||
_draftSuhuMin = widget.suhuMinLimit;
|
_draftSuhuMin = widget.suhuMinLimit.clamp(15.0, 50.0);
|
||||||
_draftSuhuMax = widget.suhuMaxLimit;
|
_draftSuhuMax = widget.suhuMaxLimit.clamp(15.0, 50.0);
|
||||||
_draftKelembapan = widget.kelembapanLimit;
|
_draftKelembapan = widget.kelembapanLimit.clamp(50.0, 100.0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -40,4 +40,12 @@ class IotRepository {
|
||||||
}
|
}
|
||||||
return Future.value([]);
|
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});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -118,4 +118,18 @@ class ApiIotService implements IotService {
|
||||||
throw Exception('Error fetching hourly history: $e');
|
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');
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import 'package:supabase_flutter/supabase_flutter.dart' as supabase;
|
import 'package:supabase_flutter/supabase_flutter.dart' as supabase;
|
||||||
import 'package:dio/dio.dart';
|
import 'package:dio/dio.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
import '../models/user_model.dart';
|
import '../models/user_model.dart';
|
||||||
import '../core/api_config.dart';
|
import '../core/api_config.dart';
|
||||||
import 'auth_service.dart';
|
import 'auth_service.dart';
|
||||||
|
|
@ -33,7 +34,7 @@ class SupabaseAuthService implements AuthService {
|
||||||
deviceId = deviceRes.data['device_id'];
|
deviceId = deviceRes.data['device_id'];
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} 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
|
// 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();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
deviceId = prefs.getString('cached_device_id');
|
deviceId = prefs.getString('cached_device_id');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('Gagal membaca cache device_id: $e');
|
debugPrint('Gagal membaca cache device_id: $e');
|
||||||
}
|
}
|
||||||
|
|
||||||
return UserModel(
|
return UserModel(
|
||||||
|
|
|
||||||