first commit

This commit is contained in:
Klimatologi Dev 2026-07-21 12:07:38 +07:00
parent 0237b64f3d
commit 749a35a9bd
11 changed files with 1216 additions and 158 deletions

View File

@ -1,2 +1,6 @@
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
android.useAndroidX=true 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

View File

@ -1,32 +1,81 @@
import 'dart:math' as math;
import 'package:fl_chart/fl_chart.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:flutter_bloc/flutter_bloc.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 '../../../blocs/authentication_bloc/authentication_bloc.dart';
import '../../../core/utils/time_series_mapper.dart';
import 'main_drawer.dart'; import 'main_drawer.dart';
class HomeScreen extends StatelessWidget { class HomeScreen extends StatefulWidget {
const HomeScreen({super.key}); const HomeScreen({super.key});
@override
State<HomeScreen> createState() => _HomeScreenState();
}
class _HomeScreenState extends State<HomeScreen> {
late Future<_DashboardData> _dashboardFuture;
String _selectedPeriod = 'Hari Ini';
bool _initialized = false;
@override
void didChangeDependencies() {
super.didChangeDependencies();
if (!_initialized) {
_dashboardFuture = _loadDashboardData(context.read<MonitoringRepository>());
_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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
drawer: const MainDrawer(), drawer: const MainDrawer(),
appBar: AppBar( appBar: AppBar(
backgroundColor: Colors.white, backgroundColor: Colors.white,
elevation: 0.5, // Kasih sedikit bayangan tipis agar elegan elevation: 0.5,
centerTitle: false, centerTitle: false,
leading: Builder( leading: Builder(
builder: (context) => IconButton( builder: (context) => IconButton(
icon: const Icon(Icons.menu, icon: const Icon(Icons.menu, color: Colors.black),
color: Colors.black), // Ikon garis 3 horizontal onPressed: () => Scaffold.of(context).openDrawer(),
onPressed: () { ),
// ini kodenya untuk membuka: ),
Scaffold.of(context).openDrawer();
},
)),
title: Row( title: Row(
children: [ children: [
// Ganti dengan Image.asset jika sudah ada logo
Image.asset( Image.asset(
'images/logo_klimatologi.png', 'images/logo_klimatologi.png',
height: 90, height: 90,
@ -38,24 +87,532 @@ class HomeScreen extends StatelessWidget {
actions: [ actions: [
IconButton( IconButton(
icon: const Icon(Icons.notifications_none, color: Colors.black), icon: const Icon(Icons.notifications_none, color: Colors.black),
onPressed: () { onPressed: () {},
// Aksi notifikasi
},
), ),
IconButton( IconButton(
icon: const Icon(Icons.logout, color: Colors.black), icon: const Icon(Icons.logout, color: Colors.black),
onPressed: () { onPressed: () {
// Trigger Logout di AuthenticationBloc kamu context.read<AuthenticationBloc>().add(AuthenticationLogoutRequested());
context
.read<AuthenticationBloc>()
.add(AuthenticationLogoutRequested());
}, },
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
], ],
), ),
body: const Center( body: FutureBuilder<_DashboardData>(
child: Text("body home page"), 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<double> _seriesForPeriod<T>(
List<T> history, {
required String period,
required DateTime Function(T) getTime,
required double Function(T) getValue,
}) {
List<double> 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<MyWindSpeed> windHistory;
final List<Evaporasi> 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<double> 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),
),
),
],
), ),
); );
} }

View File

@ -9,13 +9,14 @@ part 'atmospheric_conditions_state.dart';
class AtmosphericConditionsBloc extends Bloc<AtmosphericConditionsEvent, AtmosphericConditionsState> { class AtmosphericConditionsBloc extends Bloc<AtmosphericConditionsEvent, AtmosphericConditionsState> {
final MonitoringRepository _repository; final MonitoringRepository _repository;
StreamSubscription<AtmosphericConditions>? _subscription; StreamSubscription<AtmosphericConditions>? _subscription;
static const int _maxHistoryItems = 1500; StreamSubscription<SensorStatus>? _statusSubscription;
AtmosphericConditionsBloc({required MonitoringRepository repository}) AtmosphericConditionsBloc({required MonitoringRepository repository})
: _repository = repository, : _repository = repository,
super(const AtmosphericConditionsState()) { super(const AtmosphericConditionsState()) {
on<WatchAtmosphericConditionsStarted>(_onStarted); on<WatchAtmosphericConditionsStarted>(_onStarted);
on<_AtmosphericConditionsUpdated>(_onUpdated); on<_AtmosphericConditionsUpdated>(_onUpdated);
on<_SensorStatusUpdated>(_onStatusUpdated);
} }
/// 🚀 START /// 🚀 START
@ -26,27 +27,23 @@ class AtmosphericConditionsBloc extends Bloc<AtmosphericConditionsEvent, Atmosph
emit(state.copyWith(isLoading: true)); emit(state.copyWith(isLoading: true));
await _subscription?.cancel(); await _subscription?.cancel();
await _statusSubscription?.cancel();
// Preload today's history from Firebase history table. // Preload all humidity history from Firebase history table.
final now = DateTime.now();
final historyFromFirebase = await _repository.getSensorHistory( final historyFromFirebase = await _repository.getSensorHistory(
'/sensor/history', '/sensor/history',
(json) => AtmosphericConditions.fromJson(json), (json) => 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 = sortedHistory.isNotEmpty ? sortedHistory.last : null;
final latestFromHistory = trimmedHistory.isNotEmpty ? trimmedHistory.last : null;
emit(state.copyWith( emit(state.copyWith(
temperature: latestFromHistory?.temperature ?? state.temperature,
humidity: latestFromHistory?.humidity ?? state.humidity, humidity: latestFromHistory?.humidity ?? state.humidity,
pressure: latestFromHistory?.pressure ?? state.pressure,
altitude: latestFromHistory?.altitude ?? state.altitude,
timeMs: latestFromHistory?.timeMs ?? state.timeMs, timeMs: latestFromHistory?.timeMs ?? state.timeMs,
history: trimmedHistory, latestTimestamp: latestFromHistory?.timestamp ?? state.latestTimestamp,
history: sortedHistory,
isLoading: false, isLoading: false,
)); ));
@ -58,6 +55,15 @@ class AtmosphericConditionsBloc extends Bloc<AtmosphericConditionsEvent, Atmosph
.listen((data) { .listen((data) {
add(_AtmosphericConditionsUpdated(data)); add(_AtmosphericConditionsUpdated(data));
}); });
_statusSubscription = _repository
.getSensorStream(
'/sensor/status',
(json) => SensorStatus.fromJson(json),
)
.listen((status) {
add(_SensorStatusUpdated(status));
});
} }
/// REALTIME UPDATE /// REALTIME UPDATE
@ -65,37 +71,38 @@ class AtmosphericConditionsBloc extends Bloc<AtmosphericConditionsEvent, Atmosph
_AtmosphericConditionsUpdated event, _AtmosphericConditionsUpdated event,
Emitter<AtmosphericConditionsState> emit, Emitter<AtmosphericConditionsState> emit,
) { ) {
final now = DateTime.now(); if (!_hasValidHumidity(event.data.humidity)) {
final updatedHistory = state.history.where((item) => _isSameDay(item.timestamp, now)).toList(); return;
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( emit(state.copyWith(
temperature: event.data.temperature,
humidity: event.data.humidity, humidity: event.data.humidity,
pressure: event.data.pressure,
altitude: event.data.altitude,
timeMs: event.data.timeMs, timeMs: event.data.timeMs,
history: updatedHistory, latestTimestamp: event.data.timestamp,
isLoading: false, isLoading: false,
)); ));
} }
bool _isSameDay(DateTime a, DateTime b) { bool _hasValidHumidity(double humidity) {
return a.year == b.year && a.month == b.month && a.day == b.day; return humidity > 0.0 && humidity <= 100.0;
}
void _onStatusUpdated(
_SensorStatusUpdated event,
Emitter<AtmosphericConditionsState> emit,
) {
emit(state.copyWith(
statusOnline: event.status.online,
statusLastSeenUnixMs: event.status.lastSeenUnixMs,
statusLastAvgUploadUnixMs: event.status.lastAvgUploadUnixMs,
statusLastError: event.status.lastError,
));
} }
@override @override
Future<void> close() async { Future<void> close() async {
await _subscription?.cancel(); await _subscription?.cancel();
await _statusSubscription?.cancel();
return super.close(); return super.close();
} }
} }
@ -111,3 +118,52 @@ class _AtmosphericConditionsUpdated extends AtmosphericConditionsEvent {
data data
]; ];
} }
class _SensorStatusUpdated extends AtmosphericConditionsEvent {
final SensorStatus status;
const _SensorStatusUpdated(this.status);
@override
List<Object> 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<dynamic, dynamic> 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<Object> get props => [
online,
lastSeenUnixMs,
lastAvgUploadUnixMs,
lastError
];
}

View File

@ -1,53 +1,65 @@
part of 'atmospheric_conditions_bloc.dart'; part of 'atmospheric_conditions_bloc.dart';
class AtmosphericConditionsState extends Equatable { class AtmosphericConditionsState extends Equatable {
final double temperature;
final double humidity; final double humidity;
final double pressure;
final double altitude;
final int timeMs; final int timeMs;
final DateTime? latestTimestamp;
final List<AtmosphericConditions> history; final List<AtmosphericConditions> history;
// Status from /sensor/status
final bool statusOnline;
final int statusLastSeenUnixMs;
final int statusLastAvgUploadUnixMs;
final String statusLastError;
final bool isLoading; final bool isLoading;
const AtmosphericConditionsState({ const AtmosphericConditionsState({
this.temperature = 0.0,
this.humidity = 0.0, this.humidity = 0.0,
this.pressure = 0.0,
this.altitude = 0.0,
this.timeMs = 0, this.timeMs = 0,
this.latestTimestamp,
this.history = const [], this.history = const [],
this.statusOnline = false,
this.statusLastSeenUnixMs = 0,
this.statusLastAvgUploadUnixMs = 0,
this.statusLastError = '',
this.isLoading = true, this.isLoading = true,
}); });
AtmosphericConditionsState copyWith({ AtmosphericConditionsState copyWith({
double? temperature,
double? humidity, double? humidity,
double? pressure,
double? altitude,
int? timeMs, int? timeMs,
DateTime? latestTimestamp,
List<AtmosphericConditions>? history, List<AtmosphericConditions>? history,
bool? statusOnline,
int? statusLastSeenUnixMs,
int? statusLastAvgUploadUnixMs,
String? statusLastError,
bool? isLoading, bool? isLoading,
}) { }) {
return AtmosphericConditionsState( return AtmosphericConditionsState(
temperature: temperature ?? this.temperature,
humidity: humidity ?? this.humidity, humidity: humidity ?? this.humidity,
pressure: pressure ?? this.pressure,
altitude: altitude ?? this.altitude,
timeMs: timeMs ?? this.timeMs, timeMs: timeMs ?? this.timeMs,
latestTimestamp: latestTimestamp ?? this.latestTimestamp,
history: history ?? this.history, history: history ?? this.history,
statusOnline: statusOnline ?? this.statusOnline,
statusLastSeenUnixMs: statusLastSeenUnixMs ?? this.statusLastSeenUnixMs,
statusLastAvgUploadUnixMs: statusLastAvgUploadUnixMs ?? this.statusLastAvgUploadUnixMs,
statusLastError: statusLastError ?? this.statusLastError,
isLoading: isLoading ?? this.isLoading, isLoading: isLoading ?? this.isLoading,
); );
} }
@override @override
List<Object> get props => [ List<Object?> get props => [
temperature,
humidity, humidity,
pressure,
altitude,
timeMs, timeMs,
latestTimestamp,
history, history,
statusOnline,
statusLastSeenUnixMs,
statusLastAvgUploadUnixMs,
statusLastError,
isLoading, isLoading,
]; ];
} }

View File

@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:fl_chart/fl_chart.dart'; import 'package:fl_chart/fl_chart.dart';
import 'package:firebase_database/firebase_database.dart';
import 'package:monitoring_repository/monitoring_repository.dart'; import 'package:monitoring_repository/monitoring_repository.dart';
import '../blocs/atmospheric_conditions_bloc.dart'; import '../blocs/atmospheric_conditions_bloc.dart';
import '../../shared/utils/excel/excel_export_service.dart'; import '../../shared/utils/excel/excel_export_service.dart';
@ -28,16 +29,127 @@ String formatClockTime(DateTime timestamp) {
return "$h:$m:$s"; 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}); const AtmosphericScreen({super.key});
@override
State<AtmosphericScreen> createState() => _AtmosphericScreenState();
}
class _AtmosphericScreenState extends State<AtmosphericScreen> {
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<void> _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<AtmosphericConditions> _filteredHistory(List<AtmosphericConditions> history) {
final selectedDate = _selectedDate;
if (selectedDate == null) {
return history;
}
return history.where((item) => _isSameDay(item.timestamp, selectedDate)).toList();
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
backgroundColor: Colors.grey.shade100, backgroundColor: Colors.grey.shade100,
appBar: AppBar( appBar: AppBar(
title: const Text( title: const Text(
"Kondisi Atmosfer", "Kelembapan",
style: TextStyle(fontWeight: FontWeight.bold), style: TextStyle(fontWeight: FontWeight.bold),
), ),
centerTitle: true, centerTitle: true,
@ -51,34 +163,45 @@ class AtmosphericScreen extends StatelessWidget {
return const Center(child: CircularProgressIndicator()); return const Center(child: CircularProgressIndicator());
} }
final filteredHistory = _filteredHistory(state.history);
final latestHistory = state.history.isNotEmpty ? state.history.last : null;
return SingleChildScrollView( return SingleChildScrollView(
padding: const EdgeInsets.all(20), padding: const EdgeInsets.all(20),
child: Column( child: Column(
children: [ 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), const SizedBox(height: 24),
_historyChart(state), _historyFilterCard(filteredHistory.length),
const SizedBox(height: 16),
_historyChart(filteredHistory),
const SizedBox(height: 24), const SizedBox(height: 24),
_HistoryTableCard(history: state.history), _HistoryTableCard(history: filteredHistory),
const SizedBox(height: 24), const SizedBox(height: 24),
ExportExcelButton( ExportExcelButton(
onExport: () { onExport: () {
final historyData = state.history final historyData = filteredHistory
.map((e) => { .map((e) => {
'timeMs': e.timeMs, 'timeMs': e.timeMs,
'pressure': e.pressure, 'humidity': e.humidity,
'timestamp': e.timestamp, 'timestamp': e.timestamp,
}) })
.toList(); .toList();
return ExcelExportService.atmospheric( return ExcelExportService.atmospheric(
pressure: state.pressure, humidity: latestHistory?.humidity ?? state.humidity,
timeMs: state.timeMs, timeMs: latestHistory?.timeMs ?? state.timeMs,
timestamp: state.history.isNotEmpty ? state.history.last.timestamp : DateTime.now(), timestamp: filteredHistory.isNotEmpty ? filteredHistory.last.timestamp : DateTime.now(),
historyData: historyData, 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<DatabaseEvent>(
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 = <String>[];
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( return Container(
width: double.infinity, width: double.infinity,
padding: const EdgeInsets.symmetric(vertical: 40), padding: const EdgeInsets.symmetric(vertical: 40),
@ -113,10 +455,11 @@ class AtmosphericScreen extends StatelessWidget {
), ),
child: Column( child: Column(
children: [ 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), const SizedBox(height: 10),
Text( Text(
state.pressure.toStringAsFixed(1), humidityValue.toStringAsFixed(1),
style: const TextStyle( style: const TextStyle(
fontSize: 70, fontSize: 70,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
@ -124,34 +467,76 @@ class AtmosphericScreen extends StatelessWidget {
), ),
), ),
const Text( const Text(
"hPa", "%",
style: TextStyle(color: Colors.white70, fontSize: 18), 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) { Widget _historyChart(List<AtmosphericConditions> history) {
final history = state.history;
if (history.isEmpty) { 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 = <FlSpot>[]; final points = <FlSpot>[];
double minY = history.first.pressure; double minY = history.first.humidity;
double maxY = history.first.pressure; double maxY = history.first.humidity;
for (int i = 0; i < history.length; i++) { for (int i = 0; i < history.length; i++) {
final pressure = history[i].pressure; final humidity = history[i].humidity;
points.add(FlSpot(i.toDouble(), pressure)); points.add(FlSpot(i.toDouble(), humidity));
if (pressure < minY) { if (humidity < minY) {
minY = pressure; minY = humidity;
} }
if (pressure > maxY) { if (humidity > maxY) {
maxY = pressure; maxY = humidity;
} }
} }
@ -169,9 +554,14 @@ class AtmosphericScreen extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
const Text( const Text(
"Grafik Histori Tekanan Hari Ini", "Grafik Histori Kelembapan",
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold), 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), const SizedBox(height: 12),
SizedBox( SizedBox(
height: 220, height: 220,
@ -318,7 +708,7 @@ class _HistoryTableCardState extends State<_HistoryTableCard> {
borderRadius: BorderRadius.circular(20), borderRadius: BorderRadius.circular(20),
), ),
child: const Text( 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), style: TextStyle(color: Colors.grey),
), ),
); );
@ -338,12 +728,12 @@ class _HistoryTableCardState extends State<_HistoryTableCard> {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
const Text( const Text(
"Tabel Histori Tekanan Hari Ini", "Tabel Histori Kelembapan",
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold), style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
), ),
const SizedBox(height: 4), const SizedBox(height: 4),
Text( 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), style: const TextStyle(color: Colors.grey, fontSize: 12),
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
@ -352,8 +742,8 @@ class _HistoryTableCardState extends State<_HistoryTableCard> {
child: DataTable( child: DataTable(
columns: const [ columns: const [
DataColumn(label: Text("No")), DataColumn(label: Text("No")),
DataColumn(label: Text("Waktu")), DataColumn(label: Text("Tanggal/Waktu")),
DataColumn(label: Text("Tekanan")), DataColumn(label: Text("Kelembapan")),
], ],
rows: List<DataRow>.generate(pageItems.length, (index) { rows: List<DataRow>.generate(pageItems.length, (index) {
final item = pageItems[index]; final item = pageItems[index];
@ -361,8 +751,8 @@ class _HistoryTableCardState extends State<_HistoryTableCard> {
return DataRow( return DataRow(
cells: [ cells: [
DataCell(Text(absoluteIndex.toString())), DataCell(Text(absoluteIndex.toString())),
DataCell(Text(formatClockTime(item.timestamp))), DataCell(Text(formatDateTimeReadable(item.timestamp))),
DataCell(Text("${item.pressure.toStringAsFixed(1)} hPa")), 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}';
} }

View File

@ -4,23 +4,23 @@ import 'package:excel/excel.dart';
import 'package:file_saver/file_saver.dart'; import 'package:file_saver/file_saver.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
Future<void> exportAtmosphericExcel({ Future<String?> exportAtmosphericExcel({
required double pressure, required double humidity,
required int timeMs, required int timeMs,
required DateTime timestamp, required DateTime timestamp,
List<Map<String, dynamic>>? historyData, List<Map<String, dynamic>>? historyData,
}) async { }) async {
final excel = Excel.createExcel(); final excel = Excel.createExcel();
final sheet = excel['Histori Tekanan']; final sheet = excel['Histori Kelembapan'];
sheet.appendRow([ sheet.appendRow([
TextCellValue('Laporan Tekanan Atmosfer (Hari Ini)'), TextCellValue('Laporan Kelembapan (Semua Histori)'),
]); ]);
sheet.appendRow([ sheet.appendRow([
TextCellValue('Waktu export: ${DateFormat('dd-MM-yyyy HH:mm:ss').format(timestamp)}'), TextCellValue('Waktu export: ${DateFormat('dd-MM-yyyy HH:mm:ss').format(timestamp)}'),
]); ]);
sheet.appendRow([ sheet.appendRow([
TextCellValue('Tekanan terbaru: ${pressure.toStringAsFixed(1)} hPa'), TextCellValue('Kelembapan terbaru: ${humidity.toStringAsFixed(1)} %'),
]); ]);
sheet.appendRow([ sheet.appendRow([
TextCellValue('Uptime terbaru: ${_formatUptime(timeMs)}'), TextCellValue('Uptime terbaru: ${_formatUptime(timeMs)}'),
@ -32,7 +32,7 @@ Future<void> exportAtmosphericExcel({
sheet.appendRow([ sheet.appendRow([
TextCellValue('No'), TextCellValue('No'),
TextCellValue('Uptime'), TextCellValue('Uptime'),
TextCellValue('Tekanan (hPa)'), TextCellValue('Kelembapan (%)'),
TextCellValue('Timestamp'), TextCellValue('Timestamp'),
]); ]);
@ -40,13 +40,13 @@ Future<void> exportAtmosphericExcel({
for (int i = 0; i < rows.length; i++) { for (int i = 0; i < rows.length; i++) {
final row = rows[i]; final row = rows[i];
final rowTimeMs = _toInt(row['timeMs']); final rowTimeMs = _toInt(row['timeMs']);
final rowPressure = _toDouble(row['pressure']); final rowHumidity = _toDouble(row['humidity']);
final rowTimestamp = row['timestamp'] as DateTime?; final rowTimestamp = row['timestamp'] as DateTime?;
sheet.appendRow([ sheet.appendRow([
IntCellValue(i + 1), IntCellValue(i + 1),
TextCellValue(_formatUptime(rowTimeMs)), TextCellValue(_formatUptime(rowTimeMs)),
TextCellValue(rowPressure.toStringAsFixed(1)), TextCellValue(rowHumidity.toStringAsFixed(1)),
TextCellValue( TextCellValue(
rowTimestamp == null ? '-' : DateFormat('dd-MM-yyyy HH:mm:ss').format(rowTimestamp), rowTimestamp == null ? '-' : DateFormat('dd-MM-yyyy HH:mm:ss').format(rowTimestamp),
), ),
@ -55,17 +55,20 @@ Future<void> exportAtmosphericExcel({
final bytes = excel.encode(); final bytes = excel.encode();
if (bytes == null) { 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, name: filename,
bytes: Uint8List.fromList(bytes), bytes: Uint8List.fromList(bytes),
fileExtension: 'xlsx', fileExtension: 'xlsx',
mimeType: MimeType.other, mimeType: MimeType.other,
); );
// On Android/iOS this may be a saved path/URI depending on platform.
return result;
} }
String _formatUptime(int timeMs) { String _formatUptime(int timeMs) {

View File

@ -3,14 +3,14 @@ import 'atmospheric_excel_builder.dart';
class ExcelExportService { class ExcelExportService {
ExcelExportService._(); ExcelExportService._();
static Future<void> atmospheric({ static Future<String?> atmospheric({
required double pressure, required double humidity,
required int timeMs, required int timeMs,
required DateTime timestamp, required DateTime timestamp,
List<Map<String, dynamic>>? historyData, List<Map<String, dynamic>>? historyData,
}) => }) =>
exportAtmosphericExcel( exportAtmosphericExcel(
pressure: pressure, humidity: humidity,
timeMs: timeMs, timeMs: timeMs,
timestamp: timestamp, timestamp: timestamp,
historyData: historyData, historyData: historyData,

View File

@ -1,7 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
class ExportExcelButton extends StatefulWidget { class ExportExcelButton extends StatefulWidget {
final Future<void> Function() onExport; final Future<String?> Function() onExport;
final String label; final String label;
const ExportExcelButton({ const ExportExcelButton({
@ -22,12 +22,14 @@ class _ExportExcelButtonState extends State<ExportExcelButton> {
setState(() => _isLoading = true); setState(() => _isLoading = true);
try { try {
await widget.onExport(); final savedLocation = await widget.onExport();
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar( SnackBar(
content: Text('Export Excel berhasil'), content: Text(
savedLocation == null || savedLocation.isEmpty ? 'Export Excel berhasil' : 'Export Excel berhasil: $savedLocation',
),
), ),
); );
} }

View File

@ -26,16 +26,35 @@ class AtmosphericConditions {
fallback: now, fallback: now,
); );
final humidity = _resolveHumidity(json);
final pressure = _toDouble(json['pressure']);
return AtmosphericConditions( return AtmosphericConditions(
temperature: _toDouble(json['temperature']), temperature: _toDouble(json['temperature']),
humidity: _toDouble(json['humidity']), humidity: humidity,
pressure: _toDouble(json['pressure']), pressure: pressure,
altitude: _toDouble(json['altitude']), altitude: _toDouble(json['altitude']),
timeMs: rawTimeMs ?? 0, timeMs: rawTimeMs ?? 0,
timestamp: parsedTime, timestamp: parsedTime,
); );
} }
static double _resolveHumidity(Map<dynamic, dynamic> 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({ static DateTime _parseTimestamp({
required int? unixMs, required int? unixMs,
required int? uptimeMs, required int? uptimeMs,

View File

@ -77,10 +77,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: characters name: characters
sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.4.0" version: "1.4.1"
clock: clock:
dependency: transitive dependency: transitive
description: description:
@ -484,26 +484,26 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: matcher name: matcher
sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.12.17" version: "0.12.19"
material_color_utilities: material_color_utilities:
dependency: transitive dependency: transitive
description: description:
name: material_color_utilities name: material_color_utilities
sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.11.1" version: "0.13.0"
meta: meta:
dependency: transitive dependency: transitive
description: description:
name: meta name: meta
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.17.0" version: "1.18.0"
mime: mime:
dependency: transitive dependency: transitive
description: description:
@ -752,10 +752,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: test_api name: test_api
sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.7.7" version: "0.7.11"
typed_data: typed_data:
dependency: transitive dependency: transitive
description: description:

View File

@ -5,12 +5,11 @@
// gestures. You can also use WidgetTester to find child widgets in the widget // 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. // 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/material.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:monitoring_repository/monitoring_repository.dart'; import 'package:monitoring_repository/monitoring_repository.dart';
import 'package:user_repository/user_repository.dart'; import 'package:user_repository/user_repository.dart';
import '../lib/app.dart'; import 'package:klimatologiot/app.dart';
// import 'package:klimatologiot/main.dart'; // import 'package:klimatologiot/main.dart';
@ -49,47 +48,42 @@ class FakeUserRepository implements UserRepository {
} }
class FakeMonitoringRepository implements MonitoringRepository { class FakeMonitoringRepository implements MonitoringRepository {
get _db => null;
// Satu fungsi untuk semua jenis sensor
// Kamu cukup masukkan "path" database-nya saja
@override @override
// Jika ingin mengambil data sekali saja (bukan stream) Stream<T> getSensorStream<T>(
Future<DataSnapshot> getSensorSnapshot(String path) async { String path,
return await _db.ref(path).get(); T Function(Map<dynamic, dynamic> json) mapper,
) {
return Stream<T>.empty();
} }
@override @override
Stream<DatabaseEvent> getSensorStream(String path) { Future<T> getSensorSnapshot<T>(
// TODO: implement getSensorStream String path,
throw UnimplementedError(); T Function(Map<dynamic, dynamic> json) mapper,
) async {
return mapper(<dynamic, dynamic>{});
}
@override
Future<List<T>> getSensorHistory<T>(
String path,
T Function(Map<dynamic, dynamic> json) mapper,
) async {
return <T>[];
} }
} }
void main() { void main() {
testWidgets('App loads', (WidgetTester tester) async { testWidgets('App loads', (WidgetTester tester) async {
final fakeRepo = FakeUserRepository(); final fakeRepo = FakeUserRepository();
final fakeMonitoring = FakeUserRepository(); final fakeMonitoring = FakeMonitoringRepository();
// Build our app and trigger a frame. // Build our app and trigger a frame.
await tester.pumpWidget( await tester.pumpWidget(
MyApp(fakeRepo, fakeMonitoring as MonitoringRepository), MyApp(fakeRepo, fakeMonitoring),
); );
await tester.pump(); await tester.pump();
expect(find.byType(MaterialApp), findsOneWidget); 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);
}); });
} }