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 createState() => _HistoryPageState(); } class _HistoryPageState extends State { DateTime? _filterDate; // null = tampilkan semua TimeOfDay? _startHour; TimeOfDay? _endHour; final NodeRedClient _nodeRed = NodeRedClient(); late Future> _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 = []; 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> _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 _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> 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 _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>( 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, )), ), ], ), ); } }