From 85ac59233df2585e94a3e08bcc467619a5fed553 Mon Sep 17 00:00:00 2001 From: Falih <158024040+Falihth@users.noreply.github.com> Date: Wed, 6 May 2026 09:23:29 +0700 Subject: [PATCH] yo mboh --- .../blocs/atmospheric_conditions_bloc.dart | 113 +++++ .../blocs/atmospheric_conditions_event.dart | 10 + .../blocs/atmospheric_conditions_state.dart | 53 +++ .../views/atmospheric_screen.dart | 393 ++++++++++++++++++ 4 files changed, 569 insertions(+) create mode 100644 atmospheric_conditions/blocs/atmospheric_conditions_bloc.dart create mode 100644 atmospheric_conditions/blocs/atmospheric_conditions_event.dart create mode 100644 atmospheric_conditions/blocs/atmospheric_conditions_state.dart create mode 100644 atmospheric_conditions/views/atmospheric_screen.dart diff --git a/atmospheric_conditions/blocs/atmospheric_conditions_bloc.dart b/atmospheric_conditions/blocs/atmospheric_conditions_bloc.dart new file mode 100644 index 0000000..6ba087a --- /dev/null +++ b/atmospheric_conditions/blocs/atmospheric_conditions_bloc.dart @@ -0,0 +1,113 @@ +import 'dart:async'; +import 'package:bloc/bloc.dart'; +import 'package:equatable/equatable.dart'; +import 'package:monitoring_repository/monitoring_repository.dart'; + +part 'atmospheric_conditions_event.dart'; +part 'atmospheric_conditions_state.dart'; + +class AtmosphericConditionsBloc extends Bloc { + final MonitoringRepository _repository; + StreamSubscription? _subscription; + static const int _maxHistoryItems = 1500; + + AtmosphericConditionsBloc({required MonitoringRepository repository}) + : _repository = repository, + super(const AtmosphericConditionsState()) { + on(_onStarted); + on<_AtmosphericConditionsUpdated>(_onUpdated); + } + + /// ๐Ÿš€ START + Future _onStarted( + WatchAtmosphericConditionsStarted event, + Emitter emit, + ) async { + emit(state.copyWith(isLoading: true)); + + await _subscription?.cancel(); + + // Preload today's history from Firebase history table. + final now = DateTime.now(); + final historyFromFirebase = await _repository.getSensorHistory( + '/sensor/history', + (json) => AtmosphericConditions.fromJson(json), + ); + + final todayHistory = historyFromFirebase.where((item) => _isSameDay(item.timestamp, now)).toList()..sort((a, b) => a.timestamp.compareTo(b.timestamp)); + + final trimmedHistory = todayHistory.length > _maxHistoryItems ? todayHistory.sublist(todayHistory.length - _maxHistoryItems) : todayHistory; + + final latestFromHistory = trimmedHistory.isNotEmpty ? trimmedHistory.last : null; + + emit(state.copyWith( + temperature: latestFromHistory?.temperature ?? state.temperature, + humidity: latestFromHistory?.humidity ?? state.humidity, + pressure: latestFromHistory?.pressure ?? state.pressure, + altitude: latestFromHistory?.altitude ?? state.altitude, + timeMs: latestFromHistory?.timeMs ?? state.timeMs, + history: trimmedHistory, + isLoading: false, + )); + + _subscription = _repository + .getSensorStream( + '/sensor/latest', + (json) => AtmosphericConditions.fromJson(json), + ) + .listen((data) { + add(_AtmosphericConditionsUpdated(data)); + }); + } + + /// โšก REALTIME UPDATE + void _onUpdated( + _AtmosphericConditionsUpdated event, + Emitter emit, + ) { + final now = DateTime.now(); + final updatedHistory = state.history.where((item) => _isSameDay(item.timestamp, now)).toList(); + + final shouldAppend = updatedHistory.isEmpty || updatedHistory.last.timeMs != event.data.timeMs || updatedHistory.last.pressure != event.data.pressure || updatedHistory.last.timestamp != event.data.timestamp; + + if (shouldAppend) { + updatedHistory.add(event.data); + + if (updatedHistory.length > _maxHistoryItems) { + updatedHistory.removeAt(0); + } + } + + emit(state.copyWith( + temperature: event.data.temperature, + humidity: event.data.humidity, + pressure: event.data.pressure, + altitude: event.data.altitude, + timeMs: event.data.timeMs, + history: updatedHistory, + isLoading: false, + )); + } + + bool _isSameDay(DateTime a, DateTime b) { + return a.year == b.year && a.month == b.month && a.day == b.day; + } + + @override + Future close() async { + await _subscription?.cancel(); + return super.close(); + } +} + +/// INTERNAL EVENT +class _AtmosphericConditionsUpdated extends AtmosphericConditionsEvent { + final AtmosphericConditions data; + + const _AtmosphericConditionsUpdated(this.data); + + @override + List get props => [ + data + ]; +} diff --git a/atmospheric_conditions/blocs/atmospheric_conditions_event.dart b/atmospheric_conditions/blocs/atmospheric_conditions_event.dart new file mode 100644 index 0000000..e446497 --- /dev/null +++ b/atmospheric_conditions/blocs/atmospheric_conditions_event.dart @@ -0,0 +1,10 @@ +part of 'atmospheric_conditions_bloc.dart'; + +abstract class AtmosphericConditionsEvent extends Equatable { + const AtmosphericConditionsEvent(); + + @override + List get props => []; +} + +class WatchAtmosphericConditionsStarted extends AtmosphericConditionsEvent {} diff --git a/atmospheric_conditions/blocs/atmospheric_conditions_state.dart b/atmospheric_conditions/blocs/atmospheric_conditions_state.dart new file mode 100644 index 0000000..0860623 --- /dev/null +++ b/atmospheric_conditions/blocs/atmospheric_conditions_state.dart @@ -0,0 +1,53 @@ +part of 'atmospheric_conditions_bloc.dart'; + +class AtmosphericConditionsState extends Equatable { + final double temperature; + final double humidity; + final double pressure; + final double altitude; + final int timeMs; + final List history; + + final bool isLoading; + + const AtmosphericConditionsState({ + this.temperature = 0.0, + this.humidity = 0.0, + this.pressure = 0.0, + this.altitude = 0.0, + this.timeMs = 0, + this.history = const [], + this.isLoading = true, + }); + + AtmosphericConditionsState copyWith({ + double? temperature, + double? humidity, + double? pressure, + double? altitude, + int? timeMs, + List? history, + bool? isLoading, + }) { + return AtmosphericConditionsState( + temperature: temperature ?? this.temperature, + humidity: humidity ?? this.humidity, + pressure: pressure ?? this.pressure, + altitude: altitude ?? this.altitude, + timeMs: timeMs ?? this.timeMs, + history: history ?? this.history, + isLoading: isLoading ?? this.isLoading, + ); + } + + @override + List get props => [ + temperature, + humidity, + pressure, + altitude, + timeMs, + history, + isLoading, + ]; +} diff --git a/atmospheric_conditions/views/atmospheric_screen.dart b/atmospheric_conditions/views/atmospheric_screen.dart new file mode 100644 index 0000000..cfb7b9a --- /dev/null +++ b/atmospheric_conditions/views/atmospheric_screen.dart @@ -0,0 +1,393 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:fl_chart/fl_chart.dart'; +import 'package:monitoring_repository/monitoring_repository.dart'; +import '../blocs/atmospheric_conditions_bloc.dart'; +import '../../shared/utils/excel/excel_export_service.dart'; +import '../../shared/widgets/export_excel_button.dart'; + +String formatUptimeShort(int timeMs) { + final duration = Duration(milliseconds: timeMs); + final minutes = duration.inMinutes.remainder(60).toString().padLeft(2, '0'); + final seconds = duration.inSeconds.remainder(60).toString().padLeft(2, '0'); + return "$minutes:$seconds"; +} + +String formatUptimeLong(int timeMs) { + final duration = Duration(milliseconds: timeMs); + final hours = duration.inHours.toString().padLeft(2, '0'); + final minutes = duration.inMinutes.remainder(60).toString().padLeft(2, '0'); + final seconds = duration.inSeconds.remainder(60).toString().padLeft(2, '0'); + return "$hours:$minutes:$seconds"; +} + +String formatClockTime(DateTime timestamp) { + final h = timestamp.hour.toString().padLeft(2, '0'); + final m = timestamp.minute.toString().padLeft(2, '0'); + final s = timestamp.second.toString().padLeft(2, '0'); + return "$h:$m:$s"; +} + +class AtmosphericScreen extends StatelessWidget { + const AtmosphericScreen({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.grey.shade100, + appBar: AppBar( + title: const Text( + "Kondisi Atmosfer", + style: TextStyle(fontWeight: FontWeight.bold), + ), + centerTitle: true, + backgroundColor: Colors.transparent, + elevation: 0, + foregroundColor: Colors.black, + ), + body: BlocBuilder( + builder: (context, state) { + if (state.isLoading) { + return const Center(child: CircularProgressIndicator()); + } + + return SingleChildScrollView( + padding: const EdgeInsets.all(20), + child: Column( + children: [ + _mainPressure(state), + const SizedBox(height: 24), + _historyChart(state), + const SizedBox(height: 24), + _HistoryTableCard(history: state.history), + const SizedBox(height: 24), + ExportExcelButton( + onExport: () { + final historyData = state.history + .map((e) => { + 'timeMs': e.timeMs, + 'pressure': e.pressure, + 'timestamp': e.timestamp, + }) + .toList(); + + return ExcelExportService.atmospheric( + pressure: state.pressure, + timeMs: state.timeMs, + timestamp: state.history.isNotEmpty ? state.history.last.timestamp : DateTime.now(), + historyData: historyData, + ); + }, + label: 'Export Excel Hari Ini', + ), + ], + ), + ); + }, + ), + ); + } + + /// ========================= + /// ๐Ÿ”ต PRESSURE (HERO CARD) + /// ========================= + Widget _mainPressure(AtmosphericConditionsState state) { + return Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(vertical: 40), + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [ + const Color.fromARGB(255, 38, 255, 222), + const Color.fromARGB(255, 53, 132, 229) + ], + ), + borderRadius: BorderRadius.circular(25), + boxShadow: [ + BoxShadow( + color: const Color.fromARGB(255, 0, 191, 255).withOpacity(0.3), + blurRadius: 20, + offset: const Offset(0, 10), + ) + ], + ), + child: Column( + children: [ + const Icon(Icons.speed, color: Colors.white, size: 50), + const SizedBox(height: 10), + Text( + state.pressure.toStringAsFixed(1), + style: const TextStyle( + fontSize: 70, + fontWeight: FontWeight.bold, + color: Colors.white, + ), + ), + const Text( + "hPa", + style: TextStyle(color: Colors.white70, fontSize: 18), + ), + ], + ), + ); + } + + Widget _historyChart(AtmosphericConditionsState state) { + final history = state.history; + + if (history.isEmpty) { + return _emptyCard("Grafik histori tekanan hari ini belum ada data"); + } + + final points = []; + double minY = history.first.pressure; + double maxY = history.first.pressure; + + for (int i = 0; i < history.length; i++) { + final pressure = history[i].pressure; + points.add(FlSpot(i.toDouble(), pressure)); + + if (pressure < minY) { + minY = pressure; + } + if (pressure > maxY) { + maxY = pressure; + } + } + + final range = (maxY - minY).abs(); + final padding = range < 1 ? 0.5 : range * 0.15; + + return Container( + width: double.infinity, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(20), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + "Grafik Histori Tekanan Hari Ini", + style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold), + ), + const SizedBox(height: 12), + SizedBox( + height: 220, + child: LineChart( + LineChartData( + minX: 0, + maxX: (history.length - 1).toDouble(), + minY: minY - padding, + maxY: maxY + padding, + gridData: FlGridData(show: true), + borderData: FlBorderData(show: false), + titlesData: FlTitlesData( + topTitles: AxisTitles( + sideTitles: SideTitles(showTitles: false), + ), + rightTitles: AxisTitles( + sideTitles: SideTitles(showTitles: false), + ), + leftTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + reservedSize: 44, + interval: range < 1 ? 0.5 : null, + getTitlesWidget: (value, meta) => Text( + value.toStringAsFixed(1), + style: const TextStyle(fontSize: 10), + ), + ), + ), + bottomTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + interval: history.length > 6 ? (history.length / 5).ceilToDouble() : 1, + getTitlesWidget: (value, meta) { + final index = value.toInt(); + if (index < 0 || index >= history.length) { + return const SizedBox.shrink(); + } + return Padding( + padding: const EdgeInsets.only(top: 6), + child: Text( + formatClockTime(history[index].timestamp), + style: const TextStyle(fontSize: 10), + ), + ); + }, + ), + ), + ), + lineBarsData: [ + LineChartBarData( + spots: points, + isCurved: true, + color: Colors.blue, + barWidth: 3, + dotData: FlDotData(show: history.length <= 10), + belowBarData: BarAreaData( + show: true, + color: Colors.blue.withOpacity(0.15), + ), + ), + ], + ), + ), + ), + ], + ), + ); + } + + Widget _emptyCard(String text) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(20), + ), + child: Text( + text, + style: const TextStyle(color: Colors.grey), + ), + ); + } +} + +class _HistoryTableCard extends StatefulWidget { + final List history; + + const _HistoryTableCard({required this.history}); + + @override + State<_HistoryTableCard> createState() => _HistoryTableCardState(); +} + +class _HistoryTableCardState extends State<_HistoryTableCard> { + static const int _rowsPerPage = 10; + int _currentPage = 0; + + @override + void didUpdateWidget(covariant _HistoryTableCard oldWidget) { + super.didUpdateWidget(oldWidget); + + final totalPages = _totalPages; + if (totalPages > 0 && _currentPage >= totalPages) { + _currentPage = totalPages - 1; + } + } + + int get _totalPages { + if (widget.history.isEmpty) { + return 0; + } + return (widget.history.length / _rowsPerPage).ceil(); + } + + List get _pageItems { + final reversedHistory = widget.history.reversed.toList(); + final start = _currentPage * _rowsPerPage; + final end = (start + _rowsPerPage).clamp(0, reversedHistory.length); + return reversedHistory.sublist(start, end); + } + + void _goToPreviousPage() { + if (_currentPage > 0) { + setState(() => _currentPage--); + } + } + + void _goToNextPage() { + if (_currentPage < _totalPages - 1) { + setState(() => _currentPage++); + } + } + + @override + Widget build(BuildContext context) { + if (widget.history.isEmpty) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(20), + ), + child: const Text( + "Tabel histori tekanan hari ini belum ada data", + style: TextStyle(color: Colors.grey), + ), + ); + } + + final pageItems = _pageItems; + final hasMultiplePages = _totalPages > 1; + + return Container( + width: double.infinity, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(20), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + "Tabel Histori Tekanan Hari Ini", + style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold), + ), + const SizedBox(height: 4), + Text( + "Halaman ${_currentPage + 1} dari $_totalPages ยท ${widget.history.length} data", + style: const TextStyle(color: Colors.grey, fontSize: 12), + ), + const SizedBox(height: 12), + SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: DataTable( + columns: const [ + DataColumn(label: Text("No")), + DataColumn(label: Text("Waktu")), + DataColumn(label: Text("Tekanan")), + ], + rows: List.generate(pageItems.length, (index) { + final item = pageItems[index]; + final absoluteIndex = (_currentPage * _rowsPerPage) + index + 1; + return DataRow( + cells: [ + DataCell(Text(absoluteIndex.toString())), + DataCell(Text(formatClockTime(item.timestamp))), + DataCell(Text("${item.pressure.toStringAsFixed(1)} hPa")), + ], + ); + }), + ), + ), + if (hasMultiplePages) ...[ + const SizedBox(height: 12), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + TextButton.icon( + onPressed: _currentPage == 0 ? null : _goToPreviousPage, + icon: const Icon(Icons.chevron_left), + label: const Text('Sebelumnya'), + ), + TextButton.icon( + onPressed: _currentPage >= _totalPages - 1 ? null : _goToNextPage, + icon: const Icon(Icons.chevron_right), + label: const Text('Berikutnya'), + ), + ], + ), + ], + ], + ), + ); + } +}