TKK_E32232198/lib/page2/History.dart

1063 lines
30 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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:0010: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,
)),
),
],
),
);
}
}