diff --git a/android/gradle.properties b/android/gradle.properties index fbee1d8..d5da727 100644 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -1,2 +1,6 @@ org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError android.useAndroidX=true +# This builtInKotlin flag was added automatically by Flutter migrator +android.builtInKotlin=false +# This newDsl flag was added automatically by Flutter migrator +android.newDsl=false diff --git a/lib/screens/home/views/home_screen.dart b/lib/screens/home/views/home_screen.dart index 81e5624..c77c45e 100644 --- a/lib/screens/home/views/home_screen.dart +++ b/lib/screens/home/views/home_screen.dart @@ -1,32 +1,81 @@ +import 'dart:math' as math; + +import 'package:fl_chart/fl_chart.dart'; import 'package:flutter/material.dart'; -import 'package:google_fonts/google_fonts.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:google_fonts/google_fonts.dart'; +import 'package:monitoring_repository/monitoring_repository.dart'; + import '../../../blocs/authentication_bloc/authentication_bloc.dart'; +import '../../../core/utils/time_series_mapper.dart'; import 'main_drawer.dart'; -class HomeScreen extends StatelessWidget { +class HomeScreen extends StatefulWidget { const HomeScreen({super.key}); + @override + State createState() => _HomeScreenState(); +} + +class _HomeScreenState extends State { + late Future<_DashboardData> _dashboardFuture; + String _selectedPeriod = 'Hari Ini'; + bool _initialized = false; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + + if (!_initialized) { + _dashboardFuture = _loadDashboardData(context.read()); + _initialized = true; + } + } + + Future<_DashboardData> _loadDashboardData(MonitoringRepository repository) async { + final windHistory = await repository.getSensorHistory( + 'anemometer/history', + (json) => MyWindSpeed.fromJson(json), + ); + + final evaporasiHistory = await repository.getSensorHistory( + 'evaporasi/history', + (json) => Evaporasi.fromJson(json), + ); + + final atmosphericLatest = await repository.getSensorSnapshot( + '/sensor/latest', + (json) => AtmosphericConditions.fromJson(json), + ); + + windHistory.sort((a, b) => a.timestamp.compareTo(b.timestamp)); + evaporasiHistory.sort((a, b) => a.timestamp.compareTo(b.timestamp)); + + return _DashboardData( + windHistory: windHistory, + evaporasiHistory: evaporasiHistory, + latestWindSpeed: windHistory.isNotEmpty ? windHistory.last.speed : 0.0, + latestEvaporasi: evaporasiHistory.isNotEmpty ? evaporasiHistory.last.evaporasi : 0.0, + latestHumidityRh: atmosphericLatest.humidity, + ); + } + @override Widget build(BuildContext context) { return Scaffold( drawer: const MainDrawer(), appBar: AppBar( backgroundColor: Colors.white, - elevation: 0.5, // Kasih sedikit bayangan tipis agar elegan + elevation: 0.5, centerTitle: false, leading: Builder( - builder: (context) => IconButton( - icon: const Icon(Icons.menu, - color: Colors.black), // Ikon garis 3 horizontal - onPressed: () { - // ini kodenya untuk membuka: - Scaffold.of(context).openDrawer(); - }, - )), + builder: (context) => IconButton( + icon: const Icon(Icons.menu, color: Colors.black), + onPressed: () => Scaffold.of(context).openDrawer(), + ), + ), title: Row( children: [ - // Ganti dengan Image.asset jika sudah ada logo Image.asset( 'images/logo_klimatologi.png', height: 90, @@ -38,24 +87,532 @@ class HomeScreen extends StatelessWidget { actions: [ IconButton( icon: const Icon(Icons.notifications_none, color: Colors.black), - onPressed: () { - // Aksi notifikasi - }, + onPressed: () {}, ), IconButton( icon: const Icon(Icons.logout, color: Colors.black), onPressed: () { - // Trigger Logout di AuthenticationBloc kamu - context - .read() - .add(AuthenticationLogoutRequested()); + context.read().add(AuthenticationLogoutRequested()); }, ), const SizedBox(width: 8), ], ), - body: const Center( - child: Text("body home page"), + body: FutureBuilder<_DashboardData>( + future: _dashboardFuture, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const Center(child: CircularProgressIndicator()); + } + + if (snapshot.hasError || !snapshot.hasData) { + return Center( + child: Text( + 'Gagal memuat dashboard', + style: GoogleFonts.poppins(fontSize: 14, color: Colors.redAccent), + ), + ); + } + + final data = snapshot.data!; + + final windSeries = _seriesForPeriod( + data.windHistory, + period: _selectedPeriod, + getTime: (item) => item.timestamp, + getValue: (item) => item.speed, + ); + final evaporasiSeries = _seriesForPeriod( + data.evaporasiHistory, + period: _selectedPeriod, + getTime: (item) => item.timestamp, + getValue: (item) => item.evaporasi, + ); + + return SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Monitoring Realtime', + style: GoogleFonts.poppins( + fontSize: 18, + fontWeight: FontWeight.w700, + color: Colors.black87, + ), + ), + const SizedBox(height: 14), + LayoutBuilder( + builder: (context, constraints) { + final cardWidth = (constraints.maxWidth - 12) / 2; + + return Wrap( + spacing: 12, + runSpacing: 12, + children: [ + SizedBox( + width: cardWidth, + child: _MetricCard( + icon: Icons.air, + iconColor: const Color(0xFF6BA7E6), + value: data.latestWindSpeed, + unit: 'm/s', + label: 'Kecepatan Angin', + ), + ), + SizedBox( + width: cardWidth, + child: _MetricCard( + icon: Icons.water_drop_outlined, + iconColor: const Color(0xFF4CB3B3), + value: data.latestEvaporasi, + unit: 'mm', + label: 'Evaporasi', + badgeText: 'Tinggi', + badgeColor: const Color(0xFFEB5757), + ), + ), + SizedBox( + width: constraints.maxWidth, + child: _MetricCard( + icon: Icons.water_drop, + iconColor: const Color(0xFF4CB3B3), + value: data.latestHumidityRh, + unit: '% RH', + label: 'Kelembapan RH', + ), + ), + ], + ); + }, + ), + const SizedBox(height: 18), + Text( + 'Grafik Sensor', + style: GoogleFonts.poppins( + fontSize: 16, + fontWeight: FontWeight.w700, + color: Colors.black87, + ), + ), + const SizedBox(height: 10), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + _PeriodChip( + label: 'Hari Ini', + selected: _selectedPeriod == 'Hari Ini', + onTap: () => setState(() => _selectedPeriod = 'Hari Ini'), + ), + _PeriodChip( + label: 'Minggu Ini', + selected: _selectedPeriod == 'Minggu Ini', + onTap: () => setState(() => _selectedPeriod = 'Minggu Ini'), + ), + _PeriodChip( + label: 'Bulan Ini', + selected: _selectedPeriod == 'Bulan Ini', + onTap: () => setState(() => _selectedPeriod = 'Bulan Ini'), + ), + ], + ), + const SizedBox(height: 14), + LayoutBuilder( + builder: (context, constraints) { + final chartWidth = constraints.maxWidth > 600 ? (constraints.maxWidth - 12) / 2 : constraints.maxWidth; + + return Wrap( + spacing: 12, + runSpacing: 12, + children: [ + SizedBox( + width: chartWidth, + child: _SensorChartCard( + title: 'Kecepatan Angin', + unit: 'm/s', + currentValue: data.latestWindSpeed, + series: windSeries, + icon: Icons.air, + accentColor: const Color(0xFF6BA7E6), + subtitle: _chartSubtitle(_selectedPeriod, 'kecepatan angin'), + ), + ), + SizedBox( + width: chartWidth, + child: _SensorChartCard( + title: 'Evaporasi', + unit: 'mm', + currentValue: data.latestEvaporasi, + series: evaporasiSeries, + icon: Icons.water_drop_outlined, + accentColor: const Color(0xFF4CB3B3), + subtitle: _chartSubtitle(_selectedPeriod, 'evaporasi'), + ), + ), + ], + ); + }, + ), + ], + ), + ); + }, + ), + ); + } + + List _seriesForPeriod( + List history, { + required String period, + required DateTime Function(T) getTime, + required double Function(T) getValue, + }) { + List raw; + + if (period == 'Minggu Ini') { + raw = TimeSeriesMapper.toWeekly( + data: history, + getTime: getTime, + getValue: getValue, + ); + } else if (period == 'Bulan Ini') { + raw = TimeSeriesMapper.toMonthly( + data: history, + getTime: getTime, + getValue: getValue, + ); + } else { + raw = TimeSeriesMapper.toDaily( + data: history, + getTime: getTime, + getValue: getValue, + ); + } + + return TimeSeriesMapper.smooth(raw); + } + + String _chartSubtitle(String period, String sensorName) { + if (period == 'Hari Ini') { + return 'Rata-rata per jam hari ini'; + } + + if (period == 'Minggu Ini') { + return 'Rata-rata per hari minggu ini'; + } + + return 'Rata-rata per hari bulan ini'; + } +} + +class _DashboardData { + final List windHistory; + final List evaporasiHistory; + final double latestWindSpeed; + final double latestEvaporasi; + final double latestHumidityRh; + + const _DashboardData({ + required this.windHistory, + required this.evaporasiHistory, + required this.latestWindSpeed, + required this.latestEvaporasi, + required this.latestHumidityRh, + }); +} + +class _MetricCard extends StatelessWidget { + final IconData icon; + final Color iconColor; + final double value; + final String unit; + final String label; + final String? badgeText; + final Color? badgeColor; + + const _MetricCard({ + required this.icon, + required this.iconColor, + required this.value, + required this.unit, + required this.label, + this.badgeText, + this.badgeColor, + }); + + @override + Widget build(BuildContext context) { + final hasBadge = badgeText != null && badgeText!.isNotEmpty; + + return Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(18), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.04), + blurRadius: 12, + offset: const Offset(0, 4), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 34, + height: 34, + decoration: BoxDecoration( + color: iconColor.withOpacity(0.12), + borderRadius: BorderRadius.circular(12), + ), + child: Icon(icon, color: iconColor, size: 20), + ), + const SizedBox(height: 12), + RichText( + text: TextSpan( + style: GoogleFonts.poppins( + color: Colors.black87, + fontWeight: FontWeight.w700, + ), + children: [ + TextSpan( + text: value.toStringAsFixed(1), + style: const TextStyle(fontSize: 20), + ), + TextSpan( + text: ' $unit', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: Colors.black.withOpacity(0.55), + ), + ), + ], + ), + ), + if (hasBadge) ...[ + const SizedBox(height: 4), + Text( + badgeText!, + style: GoogleFonts.poppins( + fontSize: 11, + fontWeight: FontWeight.w600, + color: badgeColor ?? Colors.orange, + ), + ), + ], + const SizedBox(height: 4), + Text( + label, + style: GoogleFonts.poppins( + fontSize: 11, + color: Colors.black.withOpacity(0.45), + ), + ), + ], + ), + ); + } +} + +class _PeriodChip extends StatelessWidget { + final String label; + final bool selected; + final VoidCallback onTap; + + const _PeriodChip({ + required this.label, + required this.selected, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + return InkWell( + borderRadius: BorderRadius.circular(18), + onTap: onTap, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8), + decoration: BoxDecoration( + color: selected ? Colors.blue.shade600 : Colors.white, + borderRadius: BorderRadius.circular(18), + border: Border.all( + color: selected ? Colors.blue.shade600 : Colors.grey.shade300, + ), + ), + child: Text( + label, + style: GoogleFonts.poppins( + fontSize: 12, + fontWeight: FontWeight.w600, + color: selected ? Colors.white : Colors.black87, + ), + ), + ), + ); + } +} + +class _SensorChartCard extends StatelessWidget { + final String title; + final String unit; + final double currentValue; + final List series; + final IconData icon; + final Color accentColor; + final String subtitle; + + const _SensorChartCard({ + required this.title, + required this.unit, + required this.currentValue, + required this.series, + required this.icon, + required this.accentColor, + required this.subtitle, + }); + + @override + Widget build(BuildContext context) { + final hasData = series.any((value) => value.isFinite && value != 0); + final chartSeries = series.map((value) => value.isFinite ? value : 0.0).toList(); + final maxY = chartSeries.isEmpty ? 10.0 : math.max(chartSeries.reduce(math.max), 1.0) + 2; + + return Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(18), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.04), + blurRadius: 12, + offset: const Offset(0, 4), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + width: 28, + height: 28, + decoration: BoxDecoration( + color: accentColor.withOpacity(0.12), + borderRadius: BorderRadius.circular(10), + ), + child: Icon(icon, color: accentColor, size: 16), + ), + const SizedBox(width: 8), + Expanded( + child: Text( + title, + style: GoogleFonts.poppins( + fontSize: 13, + fontWeight: FontWeight.w700, + color: Colors.black87, + ), + ), + ), + ], + ), + const SizedBox(height: 8), + RichText( + text: TextSpan( + style: GoogleFonts.poppins( + color: accentColor, + fontWeight: FontWeight.w700, + ), + children: [ + TextSpan( + text: currentValue.toStringAsFixed(1), + style: const TextStyle(fontSize: 20), + ), + TextSpan( + text: ' $unit', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: Colors.black.withOpacity(0.45), + ), + ), + ], + ), + ), + const SizedBox(height: 10), + SizedBox( + height: 150, + child: hasData + ? LineChart( + LineChartData( + minX: 0, + maxX: math.max(chartSeries.length - 1, 1).toDouble(), + minY: 0, + maxY: maxY, + gridData: const FlGridData(show: false), + borderData: FlBorderData(show: false), + titlesData: const FlTitlesData( + show: false, + topTitles: AxisTitles(sideTitles: SideTitles(showTitles: false)), + rightTitles: AxisTitles(sideTitles: SideTitles(showTitles: false)), + leftTitles: AxisTitles(sideTitles: SideTitles(showTitles: false)), + bottomTitles: AxisTitles(sideTitles: SideTitles(showTitles: false)), + ), + lineBarsData: [ + LineChartBarData( + spots: chartSeries.asMap().entries.map((entry) { + return FlSpot(entry.key.toDouble(), entry.value); + }).toList(), + isCurved: true, + curveSmoothness: 0.25, + color: accentColor, + barWidth: 3, + isStrokeCapRound: true, + dotData: FlDotData( + show: chartSeries.length <= 12, + getDotPainter: (spot, percent, bar, index) { + final isLast = index == chartSeries.length - 1; + return FlDotCirclePainter( + radius: isLast ? 4 : 0, + color: isLast ? accentColor : Colors.transparent, + strokeWidth: isLast ? 2 : 0, + strokeColor: Colors.white, + ); + }, + ), + belowBarData: BarAreaData( + show: true, + color: accentColor.withOpacity(0.12), + ), + ), + ], + ), + ) + : Center( + child: Text( + 'Belum ada data', + style: GoogleFonts.poppins( + fontSize: 12, + color: Colors.grey.shade400, + ), + ), + ), + ), + const SizedBox(height: 6), + Text( + subtitle, + style: GoogleFonts.poppins( + fontSize: 11, + color: Colors.black.withOpacity(0.35), + ), + ), + ], ), ); } diff --git a/lib/screens/monitoring/atmospheric_conditions/blocs/atmospheric_conditions_bloc.dart b/lib/screens/monitoring/atmospheric_conditions/blocs/atmospheric_conditions_bloc.dart index 1931520..d76bbc5 100644 --- a/lib/screens/monitoring/atmospheric_conditions/blocs/atmospheric_conditions_bloc.dart +++ b/lib/screens/monitoring/atmospheric_conditions/blocs/atmospheric_conditions_bloc.dart @@ -9,13 +9,14 @@ part 'atmospheric_conditions_state.dart'; class AtmosphericConditionsBloc extends Bloc { final MonitoringRepository _repository; StreamSubscription? _subscription; - static const int _maxHistoryItems = 1500; + StreamSubscription? _statusSubscription; AtmosphericConditionsBloc({required MonitoringRepository repository}) : _repository = repository, super(const AtmosphericConditionsState()) { on(_onStarted); on<_AtmosphericConditionsUpdated>(_onUpdated); + on<_SensorStatusUpdated>(_onStatusUpdated); } /// 🚀 START @@ -26,27 +27,23 @@ class AtmosphericConditionsBloc extends Bloc AtmosphericConditions.fromJson(json), ); - final todayHistory = historyFromFirebase.where((item) => _isSameDay(item.timestamp, now)).toList()..sort((a, b) => a.timestamp.compareTo(b.timestamp)); + final sortedHistory = historyFromFirebase.where((item) => _hasValidHumidity(item.humidity)).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; + final latestFromHistory = sortedHistory.isNotEmpty ? sortedHistory.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, + latestTimestamp: latestFromHistory?.timestamp ?? state.latestTimestamp, + history: sortedHistory, isLoading: false, )); @@ -58,6 +55,15 @@ class AtmosphericConditionsBloc extends Bloc SensorStatus.fromJson(json), + ) + .listen((status) { + add(_SensorStatusUpdated(status)); + }); } /// ⚡ REALTIME UPDATE @@ -65,37 +71,38 @@ class AtmosphericConditionsBloc extends Bloc 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); - } + if (!_hasValidHumidity(event.data.humidity)) { + return; } 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, + latestTimestamp: event.data.timestamp, isLoading: false, )); } - bool _isSameDay(DateTime a, DateTime b) { - return a.year == b.year && a.month == b.month && a.day == b.day; + bool _hasValidHumidity(double humidity) { + return humidity > 0.0 && humidity <= 100.0; + } + + void _onStatusUpdated( + _SensorStatusUpdated event, + Emitter emit, + ) { + emit(state.copyWith( + statusOnline: event.status.online, + statusLastSeenUnixMs: event.status.lastSeenUnixMs, + statusLastAvgUploadUnixMs: event.status.lastAvgUploadUnixMs, + statusLastError: event.status.lastError, + )); } @override Future close() async { await _subscription?.cancel(); + await _statusSubscription?.cancel(); return super.close(); } } @@ -111,3 +118,52 @@ class _AtmosphericConditionsUpdated extends AtmosphericConditionsEvent { data ]; } + +class _SensorStatusUpdated extends AtmosphericConditionsEvent { + final SensorStatus status; + + const _SensorStatusUpdated(this.status); + + @override + List get props => [ + status + ]; +} + +class SensorStatus extends Equatable { + final bool online; + final int lastSeenUnixMs; + final int lastAvgUploadUnixMs; + final String lastError; + + const SensorStatus({ + required this.online, + required this.lastSeenUnixMs, + required this.lastAvgUploadUnixMs, + required this.lastError, + }); + + factory SensorStatus.fromJson(Map json) { + return SensorStatus( + online: json['online'] == true, + lastSeenUnixMs: _toInt(json['last_seen_unix_ms']) ?? 0, + lastAvgUploadUnixMs: _toInt(json['last_avg_upload_unix_ms']) ?? 0, + lastError: (json['last_error'] is String) ? (json['last_error'] as String) : '', + ); + } + + static int? _toInt(dynamic value) { + if (value is int) return value; + if (value is num) return value.toInt(); + if (value is String) return int.tryParse(value); + return null; + } + + @override + List get props => [ + online, + lastSeenUnixMs, + lastAvgUploadUnixMs, + lastError + ]; +} diff --git a/lib/screens/monitoring/atmospheric_conditions/blocs/atmospheric_conditions_state.dart b/lib/screens/monitoring/atmospheric_conditions/blocs/atmospheric_conditions_state.dart index 5a907ef..83d69e3 100644 --- a/lib/screens/monitoring/atmospheric_conditions/blocs/atmospheric_conditions_state.dart +++ b/lib/screens/monitoring/atmospheric_conditions/blocs/atmospheric_conditions_state.dart @@ -1,53 +1,65 @@ 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 DateTime? latestTimestamp; final List history; + // Status from /sensor/status + final bool statusOnline; + final int statusLastSeenUnixMs; + final int statusLastAvgUploadUnixMs; + final String statusLastError; + 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.latestTimestamp, this.history = const [], + this.statusOnline = false, + this.statusLastSeenUnixMs = 0, + this.statusLastAvgUploadUnixMs = 0, + this.statusLastError = '', this.isLoading = true, }); AtmosphericConditionsState copyWith({ - double? temperature, double? humidity, - double? pressure, - double? altitude, int? timeMs, + DateTime? latestTimestamp, List? history, + bool? statusOnline, + int? statusLastSeenUnixMs, + int? statusLastAvgUploadUnixMs, + String? statusLastError, bool? isLoading, }) { return AtmosphericConditionsState( - temperature: temperature ?? this.temperature, humidity: humidity ?? this.humidity, - pressure: pressure ?? this.pressure, - altitude: altitude ?? this.altitude, timeMs: timeMs ?? this.timeMs, + latestTimestamp: latestTimestamp ?? this.latestTimestamp, history: history ?? this.history, + statusOnline: statusOnline ?? this.statusOnline, + statusLastSeenUnixMs: statusLastSeenUnixMs ?? this.statusLastSeenUnixMs, + statusLastAvgUploadUnixMs: statusLastAvgUploadUnixMs ?? this.statusLastAvgUploadUnixMs, + statusLastError: statusLastError ?? this.statusLastError, isLoading: isLoading ?? this.isLoading, ); } @override - List get props => [ - temperature, + List get props => [ humidity, - pressure, - altitude, timeMs, + latestTimestamp, history, + statusOnline, + statusLastSeenUnixMs, + statusLastAvgUploadUnixMs, + statusLastError, isLoading, ]; } diff --git a/lib/screens/monitoring/atmospheric_conditions/views/atmospheric_screen.dart b/lib/screens/monitoring/atmospheric_conditions/views/atmospheric_screen.dart index b500864..59aafb7 100644 --- a/lib/screens/monitoring/atmospheric_conditions/views/atmospheric_screen.dart +++ b/lib/screens/monitoring/atmospheric_conditions/views/atmospheric_screen.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:fl_chart/fl_chart.dart'; +import 'package:firebase_database/firebase_database.dart'; import 'package:monitoring_repository/monitoring_repository.dart'; import '../blocs/atmospheric_conditions_bloc.dart'; import '../../shared/utils/excel/excel_export_service.dart'; @@ -28,16 +29,127 @@ String formatClockTime(DateTime timestamp) { return "$h:$m:$s"; } -class AtmosphericScreen extends StatelessWidget { +String _monthName(int month) { + const months = [ + 'Jan', + 'Feb', + 'Mar', + 'Apr', + 'Mei', + 'Jun', + 'Jul', + 'Agu', + 'Sep', + 'Okt', + 'Nov', + 'Des', + ]; + return months[month - 1]; +} + +String formatDateTimeReadable(DateTime timestamp) { + final day = timestamp.day.toString().padLeft(2, '0'); + final month = _monthName(timestamp.month); + return '$day $month ${timestamp.year}, ${formatClockTime(timestamp)}'; +} + +class _HumidityStatus { + final String title; + final String description; + + const _HumidityStatus({required this.title, required this.description}); +} + +_HumidityStatus _getHumidityStatus(double humidity) { + if (humidity < 40.0) { + return const _HumidityStatus( + title: 'Terlalu kering', + description: 'Risiko: tanaman cepat transpirasi, stres, daun menggulung.', + ); + } + + if (humidity < 60.0) { + return const _HumidityStatus( + title: 'Cukup ideal (umum)', + description: 'Biasanya nyaman untuk banyak tanaman (tergantung suhu).', + ); + } + + if (humidity <= 80.0) { + return const _HumidityStatus( + title: 'Lembap', + description: 'Mulai naik risiko jamur/penyakit daun bila sirkulasi udara buruk.', + ); + } + + return const _HumidityStatus( + title: 'Terlalu lembap / rawan penyakit', + description: 'Risiko: embun di daun, cendawan (powdery mildew/botrytis), bakteri.', + ); +} + +class AtmosphericScreen extends StatefulWidget { const AtmosphericScreen({super.key}); + @override + State createState() => _AtmosphericScreenState(); +} + +class _AtmosphericScreenState extends State { + DateTime? _selectedDate; + final DatabaseReference _sendControlRef = FirebaseDatabase.instance.ref('/sensor/control/send_enabled'); + + String get _dateFilterLabel { + final date = _selectedDate; + if (date == null) { + return 'Semua tanggal'; + } + + return formatDateOnlyReadable(date); + } + + Future _pickDate() async { + final now = DateTime.now(); + final initialDate = _selectedDate ?? now; + + final picked = await showDatePicker( + context: context, + initialDate: initialDate, + firstDate: DateTime(2020), + lastDate: now, + ); + + if (!mounted || picked == null) { + return; + } + + setState(() { + _selectedDate = DateTime(picked.year, picked.month, picked.day); + }); + } + + void _clearDateFilter() { + setState(() { + _selectedDate = null; + }); + } + + List _filteredHistory(List history) { + final selectedDate = _selectedDate; + if (selectedDate == null) { + return history; + } + + return history.where((item) => _isSameDay(item.timestamp, selectedDate)).toList(); + } + @override Widget build(BuildContext context) { return Scaffold( backgroundColor: Colors.grey.shade100, appBar: AppBar( title: const Text( - "Kondisi Atmosfer", + "Kelembapan", style: TextStyle(fontWeight: FontWeight.bold), ), centerTitle: true, @@ -51,34 +163,45 @@ class AtmosphericScreen extends StatelessWidget { return const Center(child: CircularProgressIndicator()); } + final filteredHistory = _filteredHistory(state.history); + final latestHistory = state.history.isNotEmpty ? state.history.last : null; + return SingleChildScrollView( padding: const EdgeInsets.all(20), child: Column( children: [ - _mainPressure(state), + _statusErrorCard(state), + if (_shouldShowStatusError(state)) const SizedBox(height: 16), + _sendControlCard(), + const SizedBox(height: 16), + _mainHumidity(state, latestHistory), + const SizedBox(height: 12), + _humidityStatusCard(state, latestHistory), const SizedBox(height: 24), - _historyChart(state), + _historyFilterCard(filteredHistory.length), + const SizedBox(height: 16), + _historyChart(filteredHistory), const SizedBox(height: 24), - _HistoryTableCard(history: state.history), + _HistoryTableCard(history: filteredHistory), const SizedBox(height: 24), ExportExcelButton( onExport: () { - final historyData = state.history + final historyData = filteredHistory .map((e) => { 'timeMs': e.timeMs, - 'pressure': e.pressure, + 'humidity': e.humidity, 'timestamp': e.timestamp, }) .toList(); return ExcelExportService.atmospheric( - pressure: state.pressure, - timeMs: state.timeMs, - timestamp: state.history.isNotEmpty ? state.history.last.timestamp : DateTime.now(), + humidity: latestHistory?.humidity ?? state.humidity, + timeMs: latestHistory?.timeMs ?? state.timeMs, + timestamp: filteredHistory.isNotEmpty ? filteredHistory.last.timestamp : DateTime.now(), historyData: historyData, ); }, - label: 'Export Excel Hari Ini', + label: _selectedDate == null ? 'Export Excel Semua Histori' : 'Export Excel Tanggal Ini', ), ], ), @@ -88,10 +211,229 @@ class AtmosphericScreen extends StatelessWidget { ); } + bool _isSameDay(DateTime a, DateTime b) { + return a.year == b.year && a.month == b.month && a.day == b.day; + } + + Widget _historyFilterCard(int filteredCount) { + 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( + 'Filter Histori', + style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold), + ), + const SizedBox(height: 6), + Text( + 'Tampilan: $_dateFilterLabel', + style: const TextStyle(fontSize: 12, color: Colors.black54), + ), + const SizedBox(height: 4), + Text( + 'Data terlihat: $filteredCount', + style: const TextStyle(fontSize: 12, color: Colors.black54), + ), + const SizedBox(height: 12), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + ElevatedButton.icon( + onPressed: _pickDate, + icon: const Icon(Icons.calendar_month, size: 18), + label: const Text('Pilih Tanggal'), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.blue.shade600, + foregroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + ), + ), + ), + OutlinedButton.icon( + onPressed: _selectedDate == null ? null : _clearDateFilter, + icon: const Icon(Icons.filter_alt_off, size: 18), + label: const Text('Semua Tanggal'), + style: OutlinedButton.styleFrom( + foregroundColor: Colors.black87, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + ), + ), + ), + ], + ), + ], + ), + ); + } + + bool _shouldShowStatusError(AtmosphericConditionsState state) { + final hasAnyStatus = state.statusLastSeenUnixMs > 0 || state.statusLastAvgUploadUnixMs > 0 || state.statusLastError.isNotEmpty; + if (!hasAnyStatus) { + return false; + } + + final now = DateTime.now(); + + final lastSeen = state.statusLastSeenUnixMs > 1000000000000 ? DateTime.fromMillisecondsSinceEpoch(state.statusLastSeenUnixMs).toLocal() : null; + + final lastUpload = state.statusLastAvgUploadUnixMs > 1000000000000 ? DateTime.fromMillisecondsSinceEpoch(state.statusLastAvgUploadUnixMs).toLocal() : null; + + final offline = lastSeen != null && now.difference(lastSeen) > const Duration(seconds: 35); + final historyStale = lastUpload != null && now.difference(lastUpload) > const Duration(minutes: 70); + + return offline || historyStale || state.statusLastError.isNotEmpty; + } + + Widget _sendControlCard() { + return StreamBuilder( + stream: _sendControlRef.onValue, + builder: (context, snapshot) { + final value = snapshot.data?.snapshot.value; + final enabled = value is bool + ? value + : value is num + ? value != 0 + : value is String + ? value == '1' || value.toLowerCase() == 'true' + : true; + + return Container( + width: double.infinity, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(20), + border: Border.all(color: enabled ? Colors.green.shade300 : Colors.red.shade300), + ), + child: Row( + children: [ + Icon( + enabled ? Icons.cloud_upload_outlined : Icons.cloud_off_outlined, + color: enabled ? Colors.green.shade700 : Colors.red.shade700, + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + enabled ? 'Mode kirim aktif' : 'Mode kirim dimatikan', + style: const TextStyle(fontWeight: FontWeight.bold), + ), + const SizedBox(height: 4), + Text( + enabled + ? 'Alat boleh upload data ke Firebase dari HP.' + : 'Alat tetap baca sensor, tapi tidak akan kirim data.', + style: const TextStyle(fontSize: 12, color: Colors.black54), + ), + ], + ), + ), + Switch( + value: enabled, + onChanged: (nextValue) async { + await _sendControlRef.set(nextValue ? 1 : 0); + }, + ), + ], + ), + ); + }, + ); + } + + Widget _statusErrorCard(AtmosphericConditionsState state) { + if (!_shouldShowStatusError(state)) { + return const SizedBox.shrink(); + } + + final now = DateTime.now(); + + final lastSeen = state.statusLastSeenUnixMs > 1000000000000 ? DateTime.fromMillisecondsSinceEpoch(state.statusLastSeenUnixMs).toLocal() : null; + + final lastUpload = state.statusLastAvgUploadUnixMs > 1000000000000 ? DateTime.fromMillisecondsSinceEpoch(state.statusLastAvgUploadUnixMs).toLocal() : null; + + final offline = lastSeen != null && now.difference(lastSeen) > const Duration(seconds: 35); + final historyStale = lastUpload != null && now.difference(lastUpload) > const Duration(minutes: 70); + + String headline; + if (offline) { + headline = 'Device OFFLINE (status tidak update)'; + } else if (historyStale) { + headline = 'Histori tidak update'; + } else if (state.statusLastError.isNotEmpty) { + headline = 'Firebase Error'; + } else { + headline = 'Status Error'; + } + + final details = []; + if (lastSeen != null) { + details.add('Last seen: ${formatClockTime(lastSeen)}'); + } + if (lastUpload != null) { + details.add('Last upload: ${formatClockTime(lastUpload)}'); + } + if (state.statusLastError.isNotEmpty) { + details.add('Error: ${state.statusLastError}'); + } + + return Container( + width: double.infinity, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(20), + border: Border.all(color: Colors.red.shade300), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(Icons.error_outline, color: Colors.red.shade600), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + headline, + style: TextStyle( + fontWeight: FontWeight.bold, + color: Colors.red.shade700, + ), + ), + if (details.isNotEmpty) ...[ + const SizedBox(height: 6), + Text( + details.join('\n'), + style: const TextStyle(fontSize: 12, color: Colors.black87), + ), + ] + ], + ), + ), + ], + ), + ); + } + /// ========================= - /// 🔵 PRESSURE (HERO CARD) + /// 🔵 HUMIDITY (HERO CARD) /// ========================= - Widget _mainPressure(AtmosphericConditionsState state) { + Widget _mainHumidity(AtmosphericConditionsState state, AtmosphericConditions? latestHistory) { + final humidityValue = latestHistory?.humidity ?? state.humidity; + final latestTimestamp = latestHistory?.timestamp ?? state.latestTimestamp; + return Container( width: double.infinity, padding: const EdgeInsets.symmetric(vertical: 40), @@ -113,10 +455,11 @@ class AtmosphericScreen extends StatelessWidget { ), child: Column( children: [ - const Icon(Icons.speed, color: Colors.white, size: 50), + const Icon(Icons.water_drop, color: Colors.white, size: 50), + const SizedBox(height: 6), const SizedBox(height: 10), Text( - state.pressure.toStringAsFixed(1), + humidityValue.toStringAsFixed(1), style: const TextStyle( fontSize: 70, fontWeight: FontWeight.bold, @@ -124,34 +467,76 @@ class AtmosphericScreen extends StatelessWidget { ), ), const Text( - "hPa", + "%", style: TextStyle(color: Colors.white70, fontSize: 18), ), + if (latestTimestamp != null) ...[ + const SizedBox(height: 8), + Text( + formatDateTimeReadable(latestTimestamp), + textAlign: TextAlign.center, + style: const TextStyle(color: Colors.white70, fontSize: 13), + ), + ], + ], + ), + ); + } + + Widget _humidityStatusCard(AtmosphericConditionsState state, AtmosphericConditions? latestHistory) { + if (state.history.isEmpty) { + return _emptyCard('Status kelembapan belum ada data histori'); + } + + final status = _getHumidityStatus(latestHistory?.humidity ?? state.humidity); + + 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( + 'Status Kelembapan', + style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold), + ), + const SizedBox(height: 6), + Text( + status.title, + style: const TextStyle(fontWeight: FontWeight.w600), + ), + const SizedBox(height: 4), + Text( + status.description, + style: const TextStyle(fontSize: 12, color: Colors.black87), + ), ], ), ); } - Widget _historyChart(AtmosphericConditionsState state) { - final history = state.history; - + Widget _historyChart(List history) { if (history.isEmpty) { - return _emptyCard("Grafik histori tekanan hari ini belum ada data"); + return _emptyCard("Grafik histori kelembapan belum ada data untuk tanggal ini"); } final points = []; - double minY = history.first.pressure; - double maxY = history.first.pressure; + double minY = history.first.humidity; + double maxY = history.first.humidity; for (int i = 0; i < history.length; i++) { - final pressure = history[i].pressure; - points.add(FlSpot(i.toDouble(), pressure)); + final humidity = history[i].humidity; + points.add(FlSpot(i.toDouble(), humidity)); - if (pressure < minY) { - minY = pressure; + if (humidity < minY) { + minY = humidity; } - if (pressure > maxY) { - maxY = pressure; + if (humidity > maxY) { + maxY = humidity; } } @@ -169,9 +554,14 @@ class AtmosphericScreen extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ const Text( - "Grafik Histori Tekanan Hari Ini", + "Grafik Histori Kelembapan", style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold), ), + const SizedBox(height: 4), + Text( + _selectedDate == null ? 'Menampilkan semua tanggal' : 'Tanggal: $_dateFilterLabel', + style: const TextStyle(fontSize: 12, color: Colors.black54), + ), const SizedBox(height: 12), SizedBox( height: 220, @@ -318,7 +708,7 @@ class _HistoryTableCardState extends State<_HistoryTableCard> { borderRadius: BorderRadius.circular(20), ), child: const Text( - "Tabel histori tekanan hari ini belum ada data", + "Tabel histori kelembapan belum ada data untuk tanggal ini", style: TextStyle(color: Colors.grey), ), ); @@ -338,12 +728,12 @@ class _HistoryTableCardState extends State<_HistoryTableCard> { crossAxisAlignment: CrossAxisAlignment.start, children: [ const Text( - "Tabel Histori Tekanan Hari Ini", + "Tabel Histori Kelembapan", style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold), ), const SizedBox(height: 4), Text( - "Halaman ${_currentPage + 1} dari $_totalPages · ${widget.history.length} data", + "Halaman ${_currentPage + 1} dari $_totalPages · ${widget.history.length} data · ${_dateLabel}", style: const TextStyle(color: Colors.grey, fontSize: 12), ), const SizedBox(height: 12), @@ -352,8 +742,8 @@ class _HistoryTableCardState extends State<_HistoryTableCard> { child: DataTable( columns: const [ DataColumn(label: Text("No")), - DataColumn(label: Text("Waktu")), - DataColumn(label: Text("Tekanan")), + DataColumn(label: Text("Tanggal/Waktu")), + DataColumn(label: Text("Kelembapan")), ], rows: List.generate(pageItems.length, (index) { final item = pageItems[index]; @@ -361,8 +751,8 @@ class _HistoryTableCardState extends State<_HistoryTableCard> { return DataRow( cells: [ DataCell(Text(absoluteIndex.toString())), - DataCell(Text(formatClockTime(item.timestamp))), - DataCell(Text("${item.pressure.toStringAsFixed(1)} hPa")), + DataCell(Text(formatDateTimeReadable(item.timestamp))), + DataCell(Text("${item.humidity.toStringAsFixed(1)} %")), ], ); }), @@ -390,4 +780,25 @@ class _HistoryTableCardState extends State<_HistoryTableCard> { ), ); } + + String get _dateLabel { + if (widget.history.isEmpty) { + return 'tidak ada data'; + } + + final first = widget.history.first.timestamp; + final last = widget.history.last.timestamp; + + if (first.year == last.year && first.month == last.month && first.day == last.day) { + return formatDateOnlyReadable(first); + } + + return '${formatDateOnlyReadable(first)} - ${formatDateOnlyReadable(last)}'; + } +} + +String formatDateOnlyReadable(DateTime timestamp) { + final day = timestamp.day.toString().padLeft(2, '0'); + final month = _monthName(timestamp.month); + return '$day $month ${timestamp.year}'; } diff --git a/lib/screens/monitoring/shared/utils/excel/atmospheric_excel_builder.dart b/lib/screens/monitoring/shared/utils/excel/atmospheric_excel_builder.dart index 891fd77..35433a6 100644 --- a/lib/screens/monitoring/shared/utils/excel/atmospheric_excel_builder.dart +++ b/lib/screens/monitoring/shared/utils/excel/atmospheric_excel_builder.dart @@ -4,23 +4,23 @@ import 'package:excel/excel.dart'; import 'package:file_saver/file_saver.dart'; import 'package:intl/intl.dart'; -Future exportAtmosphericExcel({ - required double pressure, +Future exportAtmosphericExcel({ + required double humidity, required int timeMs, required DateTime timestamp, List>? historyData, }) async { final excel = Excel.createExcel(); - final sheet = excel['Histori Tekanan']; + final sheet = excel['Histori Kelembapan']; sheet.appendRow([ - TextCellValue('Laporan Tekanan Atmosfer (Hari Ini)'), + TextCellValue('Laporan Kelembapan (Semua Histori)'), ]); sheet.appendRow([ TextCellValue('Waktu export: ${DateFormat('dd-MM-yyyy HH:mm:ss').format(timestamp)}'), ]); sheet.appendRow([ - TextCellValue('Tekanan terbaru: ${pressure.toStringAsFixed(1)} hPa'), + TextCellValue('Kelembapan terbaru: ${humidity.toStringAsFixed(1)} %'), ]); sheet.appendRow([ TextCellValue('Uptime terbaru: ${_formatUptime(timeMs)}'), @@ -32,7 +32,7 @@ Future exportAtmosphericExcel({ sheet.appendRow([ TextCellValue('No'), TextCellValue('Uptime'), - TextCellValue('Tekanan (hPa)'), + TextCellValue('Kelembapan (%)'), TextCellValue('Timestamp'), ]); @@ -40,13 +40,13 @@ Future exportAtmosphericExcel({ for (int i = 0; i < rows.length; i++) { final row = rows[i]; final rowTimeMs = _toInt(row['timeMs']); - final rowPressure = _toDouble(row['pressure']); + final rowHumidity = _toDouble(row['humidity']); final rowTimestamp = row['timestamp'] as DateTime?; sheet.appendRow([ IntCellValue(i + 1), TextCellValue(_formatUptime(rowTimeMs)), - TextCellValue(rowPressure.toStringAsFixed(1)), + TextCellValue(rowHumidity.toStringAsFixed(1)), TextCellValue( rowTimestamp == null ? '-' : DateFormat('dd-MM-yyyy HH:mm:ss').format(rowTimestamp), ), @@ -55,17 +55,20 @@ Future exportAtmosphericExcel({ final bytes = excel.encode(); if (bytes == null) { - return; + return null; } - final filename = 'histori_tekanan_${DateFormat('yyyyMMdd_HHmmss').format(timestamp)}'; + final filename = 'histori_kelembapan_${DateFormat('yyyyMMdd_HHmmss').format(timestamp)}'; - await FileSaver.instance.saveFile( + final result = await FileSaver.instance.saveFile( name: filename, bytes: Uint8List.fromList(bytes), fileExtension: 'xlsx', mimeType: MimeType.other, ); + + // On Android/iOS this may be a saved path/URI depending on platform. + return result; } String _formatUptime(int timeMs) { diff --git a/lib/screens/monitoring/shared/utils/excel/excel_export_service.dart b/lib/screens/monitoring/shared/utils/excel/excel_export_service.dart index e277f67..55d5ee8 100644 --- a/lib/screens/monitoring/shared/utils/excel/excel_export_service.dart +++ b/lib/screens/monitoring/shared/utils/excel/excel_export_service.dart @@ -3,14 +3,14 @@ import 'atmospheric_excel_builder.dart'; class ExcelExportService { ExcelExportService._(); - static Future atmospheric({ - required double pressure, + static Future atmospheric({ + required double humidity, required int timeMs, required DateTime timestamp, List>? historyData, }) => exportAtmosphericExcel( - pressure: pressure, + humidity: humidity, timeMs: timeMs, timestamp: timestamp, historyData: historyData, diff --git a/lib/screens/monitoring/shared/widgets/export_excel_button.dart b/lib/screens/monitoring/shared/widgets/export_excel_button.dart index 9ea2109..be20106 100644 --- a/lib/screens/monitoring/shared/widgets/export_excel_button.dart +++ b/lib/screens/monitoring/shared/widgets/export_excel_button.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; class ExportExcelButton extends StatefulWidget { - final Future Function() onExport; + final Future Function() onExport; final String label; const ExportExcelButton({ @@ -22,12 +22,14 @@ class _ExportExcelButtonState extends State { setState(() => _isLoading = true); try { - await widget.onExport(); + final savedLocation = await widget.onExport(); if (mounted) { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Export Excel berhasil'), + SnackBar( + content: Text( + savedLocation == null || savedLocation.isEmpty ? 'Export Excel berhasil' : 'Export Excel berhasil: $savedLocation', + ), ), ); } diff --git a/packages/monitoring_repository/lib/src/models/atmospheric_conditions.dart b/packages/monitoring_repository/lib/src/models/atmospheric_conditions.dart index 6122fe0..881be77 100644 --- a/packages/monitoring_repository/lib/src/models/atmospheric_conditions.dart +++ b/packages/monitoring_repository/lib/src/models/atmospheric_conditions.dart @@ -26,16 +26,35 @@ class AtmosphericConditions { fallback: now, ); + final humidity = _resolveHumidity(json); + final pressure = _toDouble(json['pressure']); + return AtmosphericConditions( temperature: _toDouble(json['temperature']), - humidity: _toDouble(json['humidity']), - pressure: _toDouble(json['pressure']), + humidity: humidity, + pressure: pressure, altitude: _toDouble(json['altitude']), timeMs: rawTimeMs ?? 0, timestamp: parsedTime, ); } + static double _resolveHumidity(Map json) { + // Preferred: explicit humidity + if (json.containsKey('humidity')) { + return _toDouble(json['humidity']); + } + + // Backward compatibility: some older payloads used `pressure` field to store humidity. + // Only treat it as humidity if it looks like %RH (0..100). + final candidate = _toDouble(json['pressure']); + if (candidate >= 0.0 && candidate <= 100.0) { + return candidate; + } + + return 0.0; + } + static DateTime _parseTimestamp({ required int? unixMs, required int? uptimeMs, diff --git a/pubspec.lock b/pubspec.lock index 1b20ac2..4792035 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -77,10 +77,10 @@ packages: dependency: transitive description: name: characters - sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b url: "https://pub.dev" source: hosted - version: "1.4.0" + version: "1.4.1" clock: dependency: transitive description: @@ -484,26 +484,26 @@ packages: dependency: transitive description: name: matcher - sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 url: "https://pub.dev" source: hosted - version: "0.12.17" + version: "0.12.19" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" url: "https://pub.dev" source: hosted - version: "0.11.1" + version: "0.13.0" meta: dependency: transitive description: name: meta - sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" + sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" url: "https://pub.dev" source: hosted - version: "1.17.0" + version: "1.18.0" mime: dependency: transitive description: @@ -752,10 +752,10 @@ packages: dependency: transitive description: name: test_api - sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 + sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" url: "https://pub.dev" source: hosted - version: "0.7.7" + version: "0.7.11" typed_data: dependency: transitive description: diff --git a/test/widget_test.dart b/test/widget_test.dart index 7a974a8..018265e 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -5,12 +5,11 @@ // gestures. You can also use WidgetTester to find child widgets in the widget // tree, read text, and verify that the values of widget properties are correct. -import 'package:firebase_database/firebase_database.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:monitoring_repository/monitoring_repository.dart'; import 'package:user_repository/user_repository.dart'; -import '../lib/app.dart'; +import 'package:klimatologiot/app.dart'; // import 'package:klimatologiot/main.dart'; @@ -49,47 +48,42 @@ class FakeUserRepository implements UserRepository { } class FakeMonitoringRepository implements MonitoringRepository { - get _db => null; - - // Satu fungsi untuk semua jenis sensor - // Kamu cukup masukkan "path" database-nya saja - @override - // Jika ingin mengambil data sekali saja (bukan stream) - Future getSensorSnapshot(String path) async { - return await _db.ref(path).get(); + Stream getSensorStream( + String path, + T Function(Map json) mapper, + ) { + return Stream.empty(); } @override - Stream getSensorStream(String path) { - // TODO: implement getSensorStream - throw UnimplementedError(); + Future getSensorSnapshot( + String path, + T Function(Map json) mapper, + ) async { + return mapper({}); + } + + @override + Future> getSensorHistory( + String path, + T Function(Map json) mapper, + ) async { + return []; } } void main() { testWidgets('App loads', (WidgetTester tester) async { final fakeRepo = FakeUserRepository(); - final fakeMonitoring = FakeUserRepository(); + final fakeMonitoring = FakeMonitoringRepository(); // Build our app and trigger a frame. await tester.pumpWidget( - MyApp(fakeRepo, fakeMonitoring as MonitoringRepository), + MyApp(fakeRepo, fakeMonitoring), ); await tester.pump(); expect(find.byType(MaterialApp), findsOneWidget); - - // Verify that our counter starts at 0. - expect(find.text('0'), findsOneWidget); - expect(find.text('1'), findsNothing); - - // Tap the '+' icon and trigger a frame. - await tester.tap(find.byIcon(Icons.add)); - await tester.pump(); - - // Verify that our counter has incremented. - expect(find.text('0'), findsNothing); - expect(find.text('1'), findsOneWidget); }); }