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
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: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<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
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<AuthenticationBloc>()
.add(AuthenticationLogoutRequested());
context.read<AuthenticationBloc>().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<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> {
final MonitoringRepository _repository;
StreamSubscription<AtmosphericConditions>? _subscription;
static const int _maxHistoryItems = 1500;
StreamSubscription<SensorStatus>? _statusSubscription;
AtmosphericConditionsBloc({required MonitoringRepository repository})
: _repository = repository,
super(const AtmosphericConditionsState()) {
on<WatchAtmosphericConditionsStarted>(_onStarted);
on<_AtmosphericConditionsUpdated>(_onUpdated);
on<_SensorStatusUpdated>(_onStatusUpdated);
}
/// 🚀 START
@ -26,27 +27,23 @@ class AtmosphericConditionsBloc extends Bloc<AtmosphericConditionsEvent, Atmosph
emit(state.copyWith(isLoading: true));
await _subscription?.cancel();
await _statusSubscription?.cancel();
// Preload today's history from Firebase history table.
final now = DateTime.now();
// Preload all humidity history from Firebase history table.
final historyFromFirebase = await _repository.getSensorHistory(
'/sensor/history',
(json) => AtmosphericConditions.fromJson(json),
);
final todayHistory = historyFromFirebase.where((item) => _isSameDay(item.timestamp, now)).toList()..sort((a, b) => a.timestamp.compareTo(b.timestamp));
final 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<AtmosphericConditionsEvent, Atmosph
.listen((data) {
add(_AtmosphericConditionsUpdated(data));
});
_statusSubscription = _repository
.getSensorStream(
'/sensor/status',
(json) => SensorStatus.fromJson(json),
)
.listen((status) {
add(_SensorStatusUpdated(status));
});
}
/// REALTIME UPDATE
@ -65,37 +71,38 @@ class AtmosphericConditionsBloc extends Bloc<AtmosphericConditionsEvent, Atmosph
_AtmosphericConditionsUpdated event,
Emitter<AtmosphericConditionsState> 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<AtmosphericConditionsState> emit,
) {
emit(state.copyWith(
statusOnline: event.status.online,
statusLastSeenUnixMs: event.status.lastSeenUnixMs,
statusLastAvgUploadUnixMs: event.status.lastAvgUploadUnixMs,
statusLastError: event.status.lastError,
));
}
@override
Future<void> 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<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';
class AtmosphericConditionsState extends Equatable {
final double temperature;
final double humidity;
final double pressure;
final double altitude;
final int timeMs;
final DateTime? latestTimestamp;
final List<AtmosphericConditions> 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<AtmosphericConditions>? 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<Object> get props => [
temperature,
List<Object?> get props => [
humidity,
pressure,
altitude,
timeMs,
latestTimestamp,
history,
statusOnline,
statusLastSeenUnixMs,
statusLastAvgUploadUnixMs,
statusLastError,
isLoading,
];
}

View File

@ -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<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
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<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(
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<AtmosphericConditions> 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 = <FlSpot>[];
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<DataRow>.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}';
}

View File

@ -4,23 +4,23 @@ import 'package:excel/excel.dart';
import 'package:file_saver/file_saver.dart';
import 'package:intl/intl.dart';
Future<void> exportAtmosphericExcel({
required double pressure,
Future<String?> exportAtmosphericExcel({
required double humidity,
required int timeMs,
required DateTime timestamp,
List<Map<String, dynamic>>? 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<void> exportAtmosphericExcel({
sheet.appendRow([
TextCellValue('No'),
TextCellValue('Uptime'),
TextCellValue('Tekanan (hPa)'),
TextCellValue('Kelembapan (%)'),
TextCellValue('Timestamp'),
]);
@ -40,13 +40,13 @@ Future<void> 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<void> 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) {

View File

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

View File

@ -1,7 +1,7 @@
import 'package:flutter/material.dart';
class ExportExcelButton extends StatefulWidget {
final Future<void> Function() onExport;
final Future<String?> Function() onExport;
final String label;
const ExportExcelButton({
@ -22,12 +22,14 @@ class _ExportExcelButtonState extends State<ExportExcelButton> {
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',
),
),
);
}

View File

@ -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<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({
required int? unixMs,
required int? uptimeMs,

View File

@ -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:

View File

@ -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<DataSnapshot> getSensorSnapshot(String path) async {
return await _db.ref(path).get();
Stream<T> getSensorStream<T>(
String path,
T Function(Map<dynamic, dynamic> json) mapper,
) {
return Stream<T>.empty();
}
@override
Stream<DatabaseEvent> getSensorStream(String path) {
// TODO: implement getSensorStream
throw UnimplementedError();
Future<T> getSensorSnapshot<T>(
String path,
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() {
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);
});
}