1063 lines
30 KiB
Dart
1063 lines
30 KiB
Dart
import 'package:firebase_auth/firebase_auth.dart';
|
||
import 'package:flutter/material.dart';
|
||
import 'package:flutter/services.dart';
|
||
import 'package:fl_chart/fl_chart.dart';
|
||
import '../http/node_red_client.dart';
|
||
import '../auth/auth_service.dart';
|
||
import '../page_auth/login.dart';
|
||
import 'Controlsystem.dart';
|
||
class Monitoring extends StatelessWidget {
|
||
const Monitoring({super.key});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return const HistoryPage();
|
||
}
|
||
}
|
||
|
||
class HistoryPage extends StatefulWidget {
|
||
const HistoryPage({super.key});
|
||
|
||
@override
|
||
State<HistoryPage> createState() => _HistoryPageState();
|
||
}
|
||
|
||
class _HistoryPageState extends State<HistoryPage> {
|
||
DateTime? _filterDate; // null = tampilkan semua
|
||
TimeOfDay? _startHour;
|
||
TimeOfDay? _endHour;
|
||
|
||
final NodeRedClient _nodeRed = NodeRedClient();
|
||
late Future<List<_HistoryItem>> _historyFuture;
|
||
|
||
/// Jumlah titik di grafik (x0 … x10).
|
||
static const int _chartPointCount = 11;
|
||
/// Jarak antar titik (menit).
|
||
static const int _chartStepMinutes = 3;
|
||
|
||
String _two(int v) => v.toString().padLeft(2, '0');
|
||
|
||
String _formatChartTime(DateTime dt) =>
|
||
'${_two(dt.hour)}:${_two(dt.minute)}';
|
||
|
||
double _niceYInterval(double span, {int maxTicks = 5}) {
|
||
if (span <= 0) return 1;
|
||
final target = span / maxTicks;
|
||
const steps = [
|
||
0.1, 0.2, 0.5, 1.0, 2.0, 5.0, 10.0, 20.0, 25.0, 50.0, 100.0,
|
||
];
|
||
for (final step in steps) {
|
||
if (step >= target) return step;
|
||
}
|
||
return (target / 50).ceil() * 50.0;
|
||
}
|
||
|
||
({double minY, double maxY, double interval, int axisDecimals}) _computeYAxis({
|
||
required double dataMin,
|
||
required double dataMax,
|
||
required bool ammonia,
|
||
}) {
|
||
var maxV = dataMax;
|
||
|
||
if (dataMin == dataMax) {
|
||
if (maxV == 0) {
|
||
return (
|
||
minY: 0.0,
|
||
maxY: 1.0,
|
||
interval: ammonia ? 0.2 : 0.2,
|
||
axisDecimals: 1,
|
||
);
|
||
}
|
||
if (ammonia) {
|
||
maxV = maxV + 0.1;
|
||
} else {
|
||
maxV = maxV < 5 ? maxV + 1 : maxV * 1.1;
|
||
}
|
||
} else {
|
||
final pad = (maxV - dataMin) * 0.12;
|
||
if (ammonia) {
|
||
maxV = maxV + (pad < 0.05 ? 0.1 : pad);
|
||
} else {
|
||
maxV = maxV + (pad < 1 ? 1 : pad);
|
||
}
|
||
}
|
||
|
||
const minY = 0.0;
|
||
final interval = _niceYInterval(maxV - minY);
|
||
var maxY = (maxV / interval).ceil() * interval;
|
||
if (maxY <= 0) maxY = interval;
|
||
|
||
final axisDecimals = interval >= 1 ? 0 : 1;
|
||
|
||
return (minY: minY, maxY: maxY, interval: interval, axisDecimals: axisDecimals);
|
||
}
|
||
|
||
String _formatAxisTickLabel(double tick, int axisDecimals) {
|
||
if (axisDecimals == 0) return tick.round().toString();
|
||
return tick.toStringAsFixed(axisDecimals);
|
||
}
|
||
|
||
/// 11 titik tiap 3 menit dalam jendela 30 menit (mis. 10:00–10:30).
|
||
List<_HistoryItem> _resampleForChart(List<_HistoryItem> items) {
|
||
if (items.isEmpty) return [];
|
||
|
||
final sorted = List<_HistoryItem>.from(items)
|
||
..sort((a, b) => a.tsMs.compareTo(b.tsMs));
|
||
|
||
final anchor = sorted.last;
|
||
final anchorDt = DateTime.fromMillisecondsSinceEpoch(anchor.tsMs);
|
||
|
||
late DateTime windowStart;
|
||
if (_startHour != null) {
|
||
windowStart = DateTime(
|
||
anchorDt.year,
|
||
anchorDt.month,
|
||
anchorDt.day,
|
||
_startHour!.hour,
|
||
_startHour!.minute,
|
||
);
|
||
} else {
|
||
windowStart = anchorDt.subtract(
|
||
const Duration(minutes: _chartStepMinutes * (_chartPointCount - 1)),
|
||
);
|
||
}
|
||
|
||
const toleranceMs = _chartStepMinutes * 60 * 1000 ~/ 2;
|
||
final result = <_HistoryItem>[];
|
||
_HistoryItem? lastKnown;
|
||
|
||
int pointCount = _chartPointCount;
|
||
|
||
if (_startHour != null && _endHour != null) {
|
||
final startMinute =
|
||
_startHour!.hour * 60 + _startHour!.minute;
|
||
|
||
final endMinute =
|
||
_endHour!.hour * 60 + _endHour!.minute;
|
||
|
||
final durationMinute = endMinute - startMinute;
|
||
|
||
pointCount =
|
||
(durationMinute / _chartStepMinutes).ceil() + 1;
|
||
}
|
||
|
||
for (int i = 0; i < pointCount; i++) {
|
||
final slotDt = windowStart.add(Duration(minutes: i * _chartStepMinutes));
|
||
final slotMs = slotDt.millisecondsSinceEpoch;
|
||
|
||
_HistoryItem? nearest;
|
||
var bestDiff = toleranceMs + 1;
|
||
|
||
for (final item in sorted) {
|
||
final diff = (item.tsMs - slotMs).abs();
|
||
if (diff <= toleranceMs && diff < bestDiff) {
|
||
bestDiff = diff;
|
||
nearest = item;
|
||
}
|
||
}
|
||
|
||
if (nearest != null) {
|
||
lastKnown = nearest;
|
||
result.add(nearest);
|
||
} else if (lastKnown != null) {
|
||
result.add(_HistoryItem(
|
||
tsMs: slotMs,
|
||
ammoniaPpm: lastKnown.ammoniaPpm,
|
||
kekeruhanNtu: lastKnown.kekeruhanNtu,
|
||
waterLevelCm: lastKnown.waterLevelCm,
|
||
));
|
||
} else {
|
||
result.add(_HistoryItem(
|
||
tsMs: slotMs,
|
||
ammoniaPpm: 0,
|
||
kekeruhanNtu: 0,
|
||
waterLevelCm: 0,
|
||
));
|
||
}
|
||
}
|
||
|
||
return result;
|
||
}
|
||
|
||
Widget _buildLineChart({
|
||
required List<_HistoryItem> items,
|
||
required int chartType,
|
||
}) {
|
||
|
||
final chartItems = _resampleForChart(items);
|
||
|
||
if (chartItems.isEmpty) {
|
||
return const SizedBox(
|
||
height: 220,
|
||
child: Center(
|
||
child: Text('Belum ada data grafik'),
|
||
),
|
||
);
|
||
}
|
||
|
||
final spots = <FlSpot>[];
|
||
|
||
for (int i = 0; i < chartItems.length; i++) {
|
||
double y;
|
||
|
||
switch (chartType) {
|
||
case 0:
|
||
y = chartItems[i].ammoniaPpm;
|
||
break;
|
||
|
||
case 1:
|
||
y = chartItems[i].kekeruhanNtu;
|
||
break;
|
||
|
||
default:
|
||
y = chartItems[i].waterLevelCm;
|
||
}
|
||
|
||
spots.add(FlSpot(i.toDouble(), y));
|
||
}
|
||
|
||
final values = spots.map((e) => e.y).toList();
|
||
|
||
double dataMinY = values.reduce((a, b) => a < b ? a : b);
|
||
double dataMaxY = values.reduce((a, b) => a > b ? a : b);
|
||
|
||
if (dataMinY == dataMaxY) {
|
||
dataMinY = 0;
|
||
dataMaxY = dataMaxY == 0 ? 1 : dataMaxY + 0.1;
|
||
}
|
||
|
||
if (dataMinY < 0) dataMinY = 0;
|
||
|
||
final isAmmonia = chartType == 0;
|
||
|
||
final yAxis = _computeYAxis(
|
||
dataMin: dataMinY,
|
||
dataMax: dataMaxY,
|
||
ammonia: isAmmonia,
|
||
);
|
||
final minY = yAxis.minY;
|
||
final maxY = yAxis.maxY;
|
||
final yInterval = yAxis.interval;
|
||
final axisDecimals = yAxis.axisDecimals;
|
||
final leftReservedSize = maxY >= 100 ? 38.0 : 28.0;
|
||
final tooltipDecimals = isAmmonia ? 2 : 1;
|
||
|
||
final labelStep =
|
||
chartItems.length <= 11
|
||
? 1
|
||
: (chartItems.length / 6).ceil();
|
||
|
||
return Container(
|
||
padding: const EdgeInsets.all(12),
|
||
decoration: BoxDecoration(
|
||
color: Colors.white,
|
||
borderRadius: BorderRadius.circular(16),
|
||
boxShadow: [
|
||
BoxShadow(
|
||
color: Colors.black.withOpacity(0.08),
|
||
blurRadius: 8,
|
||
),
|
||
],
|
||
),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
|
||
Text(
|
||
chartType == 0
|
||
? 'Amonia'
|
||
: chartType == 1
|
||
?'Kekeruhan'
|
||
:'Ketinggian Air',
|
||
style: const TextStyle(
|
||
fontWeight: FontWeight.bold,
|
||
fontSize: 16,
|
||
color: Color(0xFF2476C7),
|
||
),
|
||
),
|
||
|
||
const SizedBox(height: 12),
|
||
|
||
Expanded(
|
||
child: LineChart(
|
||
LineChartData(
|
||
minX: 0,
|
||
maxX: (chartItems.length - 1).toDouble(),
|
||
minY: minY,
|
||
maxY: maxY,
|
||
lineTouchData: LineTouchData(
|
||
enabled: true,
|
||
touchTooltipData: LineTouchTooltipData(
|
||
getTooltipColor: (_) => const Color(0xFF2476C7),
|
||
tooltipPadding: const EdgeInsets.symmetric(
|
||
horizontal: 10,
|
||
vertical: 6,
|
||
),
|
||
getTooltipItems: (touchedSpots) {
|
||
return touchedSpots.map((spot) {
|
||
return LineTooltipItem(
|
||
spot.y.toStringAsFixed(tooltipDecimals),
|
||
const TextStyle(
|
||
color: Colors.white,
|
||
fontSize: 12,
|
||
fontWeight: FontWeight.bold,
|
||
),
|
||
);
|
||
}).toList();
|
||
},
|
||
),
|
||
),
|
||
|
||
gridData: FlGridData(
|
||
show: true,
|
||
horizontalInterval: yInterval,
|
||
),
|
||
|
||
borderData: FlBorderData(show: true),
|
||
|
||
titlesData: FlTitlesData(
|
||
|
||
topTitles: const AxisTitles(
|
||
sideTitles: SideTitles(showTitles: false),
|
||
),
|
||
|
||
leftTitles: AxisTitles(
|
||
sideTitles: SideTitles(
|
||
showTitles: true,
|
||
reservedSize: leftReservedSize,
|
||
interval: yInterval,
|
||
getTitlesWidget: (value, meta) {
|
||
if (value < minY - 0.001 || value > maxY + 0.001) {
|
||
return const SizedBox.shrink();
|
||
}
|
||
|
||
final step = ((value - minY) / yInterval).round();
|
||
final tick = minY + step * yInterval;
|
||
if ((value - tick).abs() > yInterval * 0.01) {
|
||
return const SizedBox.shrink();
|
||
}
|
||
|
||
return SideTitleWidget(
|
||
meta: meta,
|
||
space: 4,
|
||
child: Text(
|
||
_formatAxisTickLabel(tick, axisDecimals),
|
||
style: const TextStyle(fontSize: 10),
|
||
),
|
||
);
|
||
},
|
||
),
|
||
),
|
||
|
||
rightTitles: const AxisTitles(
|
||
sideTitles: SideTitles(showTitles: false),
|
||
),
|
||
|
||
bottomTitles: AxisTitles(
|
||
sideTitles: SideTitles(
|
||
showTitles: true,
|
||
reservedSize: 42,
|
||
interval: 1,
|
||
getTitlesWidget: (value, meta) {
|
||
final i = value.round();
|
||
if (i % labelStep != 0 &&
|
||
i != chartItems.length - 1) {
|
||
return const SizedBox.shrink();
|
||
}
|
||
if ((value - i).abs() > 0.01) {
|
||
return const SizedBox.shrink();
|
||
}
|
||
if (i < 0 || i >= chartItems.length) {
|
||
return const SizedBox.shrink();
|
||
}
|
||
|
||
final dt = DateTime.fromMillisecondsSinceEpoch(
|
||
chartItems[i].tsMs,
|
||
);
|
||
|
||
return SideTitleWidget(
|
||
meta: meta,
|
||
space: 8,
|
||
child: Padding(
|
||
padding: const EdgeInsets.only(top: 6),
|
||
child: Transform.rotate(
|
||
angle: -1.5708,
|
||
child: Text(
|
||
_formatChartTime(dt),
|
||
style: const TextStyle(fontSize: 9),
|
||
textAlign: TextAlign.center,
|
||
),
|
||
),
|
||
),
|
||
);
|
||
},
|
||
),
|
||
),
|
||
),
|
||
|
||
lineBarsData: [
|
||
|
||
LineChartBarData(
|
||
spots: spots,
|
||
isCurved: false,
|
||
barWidth: 3,
|
||
dotData: FlDotData(show: true),
|
||
belowBarData: BarAreaData(
|
||
show: true,
|
||
cutOffY: minY,
|
||
applyCutOffY: true,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
bool _matchesFilter(_HistoryItem item) {
|
||
final dt = DateTime.fromMillisecondsSinceEpoch(item.tsMs);
|
||
|
||
// filter tanggal
|
||
final now = DateTime.now();
|
||
|
||
final targetDate = _filterDate ??
|
||
DateTime(now.year, now.month, now.day);
|
||
|
||
if (dt.day != targetDate.day ||
|
||
dt.month != targetDate.month ||
|
||
dt.year != targetDate.year) {
|
||
return false;
|
||
}
|
||
// Filter jam
|
||
if (_startHour != null && _endHour != null) {
|
||
final totalMinute = dt.hour * 60 + dt.minute;
|
||
|
||
final startMinute =
|
||
_startHour!.hour * 60 + _startHour!.minute;
|
||
|
||
final endMinute =
|
||
_endHour!.hour * 60 + _endHour!.minute;
|
||
|
||
if (totalMinute < startMinute ||
|
||
totalMinute > endMinute) {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
return true;
|
||
}
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
SystemChrome.setPreferredOrientations([
|
||
DeviceOrientation.portraitUp,
|
||
DeviceOrientation.portraitDown,
|
||
DeviceOrientation.landscapeLeft,
|
||
DeviceOrientation.landscapeRight,
|
||
]);
|
||
_historyFuture = _loadItems();
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
SystemChrome.setPreferredOrientations([
|
||
DeviceOrientation.portraitUp,
|
||
]);
|
||
super.dispose();
|
||
}
|
||
|
||
Widget _buildFilterSection(bool isLandscape) {
|
||
final datePicker = GestureDetector(
|
||
onTap: _pickFilterDate,
|
||
child: Row(
|
||
children: [
|
||
const Icon(
|
||
Icons.calendar_month,
|
||
color: Color(0xFF2476C7),
|
||
size: 24,
|
||
),
|
||
const SizedBox(width: 10),
|
||
Expanded(
|
||
child: Text(
|
||
_filterDate == null
|
||
? 'Pilih tanggal'
|
||
: '${_two(_filterDate!.day)}-${_two(_filterDate!.month)}-${_filterDate!.year}',
|
||
style: TextStyle(
|
||
fontSize: 14,
|
||
color: _filterDate == null
|
||
? Colors.grey[600]
|
||
: const Color(0xFF2476C7),
|
||
fontWeight: FontWeight.w600,
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
|
||
Widget timeField({
|
||
required TimeOfDay? value,
|
||
required String placeholder,
|
||
required VoidCallback onTap,
|
||
}) {
|
||
return GestureDetector(
|
||
onTap: onTap,
|
||
child: Container(
|
||
padding: EdgeInsets.symmetric(
|
||
horizontal: 12,
|
||
vertical: isLandscape ? 8 : 12,
|
||
),
|
||
decoration: BoxDecoration(
|
||
border: Border.all(color: const Color(0xFF2476C7)),
|
||
borderRadius: BorderRadius.circular(10),
|
||
),
|
||
child: Text(
|
||
value == null
|
||
? placeholder
|
||
: '${_two(value.hour)}:${_two(value.minute)}',
|
||
style: const TextStyle(fontSize: 13),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
final startTime = timeField(
|
||
value: _startHour,
|
||
placeholder: 'Jam awal',
|
||
onTap: () async {
|
||
final picked = await showTimePicker(
|
||
context: context,
|
||
initialTime: _startHour ?? TimeOfDay.now(),
|
||
);
|
||
if (picked != null) {
|
||
setState(() => _startHour = picked);
|
||
}
|
||
},
|
||
);
|
||
|
||
final endTime = timeField(
|
||
value: _endHour,
|
||
placeholder: 'Jam akhir',
|
||
onTap: () async {
|
||
final picked = await showTimePicker(
|
||
context: context,
|
||
initialTime: _endHour ?? TimeOfDay.now(),
|
||
);
|
||
if (picked != null) {
|
||
setState(() => _endHour = picked);
|
||
}
|
||
},
|
||
);
|
||
|
||
final resetButton = _filterDate != null
|
||
? TextButton(
|
||
onPressed: () {
|
||
setState(() {
|
||
_filterDate = null;
|
||
_startHour = null;
|
||
_endHour = null;
|
||
_historyFuture = _loadItems();
|
||
});
|
||
},
|
||
child: const Text('Reset', style: TextStyle(fontSize: 12)),
|
||
)
|
||
: null;
|
||
|
||
if (isLandscape) {
|
||
return Row(
|
||
children: [
|
||
Expanded(flex: 3, child: datePicker),
|
||
const SizedBox(width: 8),
|
||
Expanded(flex: 2, child: startTime),
|
||
const SizedBox(width: 8),
|
||
Expanded(flex: 2, child: endTime),
|
||
if (resetButton != null) resetButton,
|
||
],
|
||
);
|
||
}
|
||
|
||
return Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Row(
|
||
children: [
|
||
Expanded(child: datePicker),
|
||
if (resetButton != null) resetButton,
|
||
],
|
||
),
|
||
const SizedBox(height: 12),
|
||
Row(
|
||
children: [
|
||
Expanded(child: startTime),
|
||
const SizedBox(width: 12),
|
||
Expanded(child: endTime),
|
||
],
|
||
),
|
||
],
|
||
);
|
||
}
|
||
|
||
Widget _buildFilterCard(bool isLandscape) {
|
||
return Container(
|
||
padding: EdgeInsets.symmetric(
|
||
horizontal: 12,
|
||
vertical: isLandscape ? 8 : 12,
|
||
),
|
||
decoration: BoxDecoration(
|
||
color: Colors.white,
|
||
borderRadius: BorderRadius.circular(12),
|
||
boxShadow: [
|
||
BoxShadow(
|
||
color: Colors.black.withOpacity(0.06),
|
||
blurRadius: 8,
|
||
offset: const Offset(0, 2),
|
||
),
|
||
],
|
||
),
|
||
child: _buildFilterSection(isLandscape),
|
||
);
|
||
}
|
||
|
||
Widget _buildChartsSection({
|
||
required List<_HistoryItem> items,
|
||
required double chartHeight,
|
||
}) {
|
||
return Column(
|
||
children: [
|
||
SizedBox(
|
||
height: chartHeight,
|
||
child: _buildLineChart(items: items, chartType: 0),
|
||
),
|
||
const SizedBox(height: 16),
|
||
SizedBox(
|
||
height: chartHeight,
|
||
child: _buildLineChart(items: items, chartType: 1),
|
||
),
|
||
const SizedBox(height: 16),
|
||
SizedBox(
|
||
height: chartHeight,
|
||
child: _buildLineChart(items: items, chartType: 2),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
|
||
Future<List<_HistoryItem>> _loadItems() async {
|
||
|
||
// Default = hari ini
|
||
final now = DateTime.now();
|
||
|
||
final usedDate = _filterDate ??
|
||
DateTime(now.year, now.month, now.day);
|
||
|
||
final selectedDate =
|
||
'${usedDate.year}-${_two(usedDate.month)}-${_two(usedDate.day)}';
|
||
|
||
final rows = await _nodeRed.fetchHistory(
|
||
limit: 1000,
|
||
date: selectedDate,
|
||
);
|
||
|
||
List<_HistoryItem> items;
|
||
|
||
if (rows.isNotEmpty) {
|
||
items = _itemsFromRows(rows);
|
||
} else {
|
||
final localRows = await AuthService.getLocalHistoryRows();
|
||
items = _itemsFromRows(localRows);
|
||
}
|
||
|
||
items.sort((a, b) => b.tsMs.compareTo(a.tsMs));
|
||
return items;
|
||
}
|
||
|
||
Future<void> _refresh() async {
|
||
final next = _loadItems();
|
||
if (!mounted) return;
|
||
setState(() {
|
||
_historyFuture = next;
|
||
});
|
||
try {
|
||
await next;
|
||
} catch (_) {
|
||
// Error sudah ditangani oleh FutureBuilder (snapshot.hasError).
|
||
}
|
||
}
|
||
|
||
List<_HistoryItem> _itemsFromRows(List<Map<String, dynamic>> rows) {
|
||
final items = <_HistoryItem>[];
|
||
for (final value in rows) {
|
||
final tsMs = parseHistoryTimestampMs(
|
||
value['ts'] ??
|
||
value['time'] ??
|
||
value['timestamp'] ??
|
||
value['created_at'],
|
||
);
|
||
if (tsMs <= 0) continue;
|
||
|
||
final ammoniaRaw =
|
||
value['amonia'] ?? value['ammonia'] ?? value['ph'];
|
||
final ammonia = historyAmmoniaDecimal(
|
||
ammoniaRaw is num
|
||
? ammoniaRaw.toDouble()
|
||
: double.tryParse(ammoniaRaw?.toString() ?? '') ?? 0.0,
|
||
);
|
||
final turbRaw =
|
||
value['kekeruhan'] ?? value['turbidity'] ?? value['ntu'];
|
||
final kekeruhan = historyChartDecimal(
|
||
turbRaw is num
|
||
? turbRaw.toDouble()
|
||
: double.tryParse(turbRaw?.toString() ?? '') ?? 0.0,
|
||
);
|
||
final waterLevel = parseHistoryWaterLevel(value);
|
||
|
||
items.add(_HistoryItem(
|
||
tsMs: tsMs,
|
||
ammoniaPpm: ammonia,
|
||
kekeruhanNtu: kekeruhan,
|
||
waterLevelCm: waterLevel,
|
||
));
|
||
}
|
||
|
||
items.sort((a, b) => b.tsMs.compareTo(a.tsMs));
|
||
return items;
|
||
}
|
||
|
||
Future<void> _pickFilterDate() async {
|
||
final picked = await showDatePicker(
|
||
context: context,
|
||
initialDate: _filterDate ?? DateTime.now(),
|
||
firstDate: DateTime(2020),
|
||
lastDate: DateTime.now(),
|
||
);
|
||
if (picked != null && mounted) {
|
||
setState(() {
|
||
_filterDate = picked;
|
||
_historyFuture = _loadItems();
|
||
});
|
||
}
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final media = MediaQuery.of(context);
|
||
final isLandscape = media.orientation == Orientation.landscape;
|
||
final screenHeight = media.size.height;
|
||
final headerTop = isLandscape ? 56.0 : 80.0;
|
||
final contentTop = headerTop + 8;
|
||
final chartHeight = isLandscape ? 220.0 : 280.0;
|
||
|
||
if (FirebaseAuth.instance.currentUser == null) {
|
||
return const Scaffold(
|
||
body: Center(child: Text('Silakan login terlebih dulu')),
|
||
);
|
||
}
|
||
|
||
return Scaffold(
|
||
resizeToAvoidBottomInset: false,
|
||
body: Stack(
|
||
children: [
|
||
// Background gradien biru ke putih
|
||
Container(
|
||
decoration: const BoxDecoration(
|
||
gradient: LinearGradient(
|
||
begin: Alignment.topCenter,
|
||
end: Alignment.bottomCenter,
|
||
colors: [
|
||
Color(0xFF3A8DDF),
|
||
Color(0xFF6EB6FF),
|
||
Colors.white,
|
||
],
|
||
stops: [0.0, 0.5, 1.0],
|
||
),
|
||
),
|
||
),
|
||
// Background putih melengkung di atas
|
||
Positioned(
|
||
top: 0,
|
||
left: 0,
|
||
right: 0,
|
||
child: Container(
|
||
height: headerTop,
|
||
decoration: const BoxDecoration(
|
||
color: Colors.white,
|
||
borderRadius: BorderRadius.only(
|
||
bottomLeft: Radius.circular(30),
|
||
bottomRight: Radius.circular(30),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
// Header
|
||
Positioned(
|
||
top: isLandscape ? 16 : 32,
|
||
left: 0,
|
||
right: 0,
|
||
child: Center(
|
||
child: Text(
|
||
'HISTORY',
|
||
style: TextStyle(
|
||
fontSize: isLandscape ? 22 : 30,
|
||
fontWeight: FontWeight.bold,
|
||
color: const Color(0xFF2476C7),
|
||
shadows: [
|
||
Shadow(
|
||
offset: const Offset(2, 2),
|
||
blurRadius: 4,
|
||
color: Colors.black.withOpacity(0.08),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
),
|
||
Positioned.fill(
|
||
top: contentTop,
|
||
child: FutureBuilder<List<_HistoryItem>>(
|
||
future: _historyFuture,
|
||
builder: (context, snapshot) {
|
||
if (snapshot.connectionState == ConnectionState.waiting) {
|
||
return SingleChildScrollView(
|
||
padding: const EdgeInsets.fromLTRB(18, 4, 18, 16),
|
||
child: Column(
|
||
children: [
|
||
_buildFilterCard(isLandscape),
|
||
const SizedBox(height: 48),
|
||
const Center(child: CircularProgressIndicator()),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget bodyChild;
|
||
if (snapshot.hasError) {
|
||
bodyChild = Column(
|
||
children: [
|
||
SizedBox(height: screenHeight * 0.15),
|
||
Padding(
|
||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||
child: Text(
|
||
'Gagal memuat history.\nTarik ke bawah untuk coba lagi.\n\n${snapshot.error}',
|
||
textAlign: TextAlign.center,
|
||
style: TextStyle(color: Colors.grey[700]),
|
||
),
|
||
),
|
||
],
|
||
);
|
||
} else {
|
||
final allItems = snapshot.data ?? [];
|
||
final items = allItems.where(_matchesFilter).toList();
|
||
|
||
if (allItems.isEmpty) {
|
||
bodyChild = Column(
|
||
children: [
|
||
SizedBox(height: screenHeight * 0.15),
|
||
Center(
|
||
child: Text(
|
||
'Belum ada data history',
|
||
style: TextStyle(
|
||
color: Colors.grey[700],
|
||
fontSize: 16,
|
||
),
|
||
),
|
||
),
|
||
],
|
||
);
|
||
} else if (items.isEmpty) {
|
||
bodyChild = Column(
|
||
children: [
|
||
SizedBox(height: screenHeight * 0.1),
|
||
Center(
|
||
child: Column(
|
||
children: [
|
||
Text(
|
||
'Tidak ada data pada ${_filterDate != null ? '${_two(_filterDate!.day)}-${_two(_filterDate!.month)}-${_filterDate!.year}' : 'tanggal tersebut'}',
|
||
textAlign: TextAlign.center,
|
||
style: TextStyle(color: Colors.grey[700]),
|
||
),
|
||
TextButton(
|
||
onPressed: () {
|
||
setState(() {
|
||
_filterDate = null;
|
||
_startHour = null;
|
||
_endHour = null;
|
||
});
|
||
},
|
||
child: const Text('Tampilkan semua'),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
);
|
||
} else {
|
||
bodyChild = _buildChartsSection(
|
||
items: items,
|
||
chartHeight: chartHeight,
|
||
);
|
||
}
|
||
}
|
||
|
||
return RefreshIndicator(
|
||
onRefresh: _refresh,
|
||
child: SingleChildScrollView(
|
||
physics: const AlwaysScrollableScrollPhysics(),
|
||
padding: const EdgeInsets.fromLTRB(18, 4, 18, 16),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
children: [
|
||
_buildFilterCard(isLandscape),
|
||
const SizedBox(height: 12),
|
||
bodyChild,
|
||
],
|
||
),
|
||
),
|
||
);
|
||
},
|
||
),
|
||
),
|
||
],
|
||
),
|
||
bottomNavigationBar: const CustomBottomNav(
|
||
currentIndex: 1,
|
||
replaceUserWithLogout: true,
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _HistoryItem {
|
||
final int tsMs;
|
||
final double ammoniaPpm;
|
||
final double kekeruhanNtu;
|
||
final double waterLevelCm;
|
||
|
||
const _HistoryItem({
|
||
required this.tsMs,
|
||
required this.ammoniaPpm,
|
||
required this.kekeruhanNtu,
|
||
required this.waterLevelCm,
|
||
});
|
||
}
|
||
|
||
class CustomBottomNav extends StatelessWidget {
|
||
/// 0: ControlSystem, 1: History, 2: User atau Logout
|
||
final int currentIndex;
|
||
/// Jika true, tombol ketiga menjadi Logout (untuk halaman History)
|
||
final bool replaceUserWithLogout;
|
||
|
||
const CustomBottomNav({
|
||
required this.currentIndex,
|
||
this.replaceUserWithLogout = false,
|
||
super.key,
|
||
});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Container(
|
||
decoration: const BoxDecoration(
|
||
color: Color(0xFF2476C7),
|
||
borderRadius: BorderRadius.only(
|
||
topLeft: Radius.circular(30),
|
||
topRight: Radius.circular(30),
|
||
),
|
||
),
|
||
height: 64,
|
||
child: Row(
|
||
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
||
children: [
|
||
GestureDetector(
|
||
onTap: () {
|
||
if (currentIndex != 0) {
|
||
Navigator.of(context).pushReplacement(
|
||
MaterialPageRoute(builder: (context) => const ControlSystem()),
|
||
);
|
||
}
|
||
},
|
||
child: currentIndex == 0
|
||
? Container(
|
||
width: 48,
|
||
height: 48,
|
||
decoration: const BoxDecoration(
|
||
color: Colors.white,
|
||
shape: BoxShape.circle,
|
||
),
|
||
child: const Icon(
|
||
Icons.computer,
|
||
color: Color(0xFF2476C7),
|
||
size: 28,
|
||
),
|
||
)
|
||
: const Icon(
|
||
Icons.computer,
|
||
color: Colors.white,
|
||
size: 28,
|
||
),
|
||
),
|
||
GestureDetector(
|
||
onTap: () {}, // sudah di History
|
||
child: currentIndex == 1
|
||
? Container(
|
||
width: 48,
|
||
height: 48,
|
||
decoration: const BoxDecoration(
|
||
color: Colors.white,
|
||
shape: BoxShape.circle,
|
||
),
|
||
child: const Icon(
|
||
Icons.history,
|
||
color: Color(0xFF2476C7),
|
||
size: 28,
|
||
),
|
||
)
|
||
: const Icon(
|
||
Icons.history,
|
||
color: Colors.white,
|
||
size: 28,
|
||
),
|
||
),
|
||
GestureDetector(
|
||
onTap: () async {
|
||
if (replaceUserWithLogout) {
|
||
await FirebaseAuth.instance.signOut();
|
||
await AuthService.logout();
|
||
if (context.mounted) {
|
||
Navigator.pushReplacement(
|
||
context,
|
||
MaterialPageRoute(builder: (context) => const Login()),
|
||
);
|
||
}
|
||
}
|
||
},
|
||
child: replaceUserWithLogout
|
||
? const Icon(Icons.logout, color: Colors.white, size: 28)
|
||
: (currentIndex == 2
|
||
? Container(
|
||
width: 48,
|
||
height: 48,
|
||
decoration: const BoxDecoration(
|
||
color: Colors.white,
|
||
shape: BoxShape.circle,
|
||
),
|
||
child: const Icon(
|
||
Icons.person,
|
||
color: Color(0xFF2476C7),
|
||
size: 28,
|
||
),
|
||
)
|
||
: const Icon(
|
||
Icons.person,
|
||
color: Colors.white,
|
||
size: 28,
|
||
)),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|