upload projek
|
|
@ -91,3 +91,8 @@ key.properties
|
|||
.env.*
|
||||
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
|
||||
|
||||
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 |
|
||||
|---|---|---|---|
|
||||
| 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) {
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
alignment: Alignment.bottomLeft,
|
||||
child: Text(
|
||||
subtitle,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(color: Colors.white70, fontSize: 12),
|
||||
),
|
||||
Align(
|
||||
alignment: Alignment.bottomLeft,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
subtitle,
|
||||
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,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
itemCount: schedules.length,
|
||||
separatorBuilder: (_, __) => Divider(
|
||||
separatorBuilder: (context, index) => Divider(
|
||||
height: 1,
|
||||
color: Colors.grey.shade200,
|
||||
indent: 16,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||