first commit
This commit is contained in:
parent
0237b64f3d
commit
749a35a9bd
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
20
pubspec.lock
20
pubspec.lock
|
|
@ -77,10 +77,10 @@ packages:
|
|||
dependency: transitive
|
||||
description:
|
||||
name: characters
|
||||
sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803
|
||||
sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.0"
|
||||
version: "1.4.1"
|
||||
clock:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -484,26 +484,26 @@ packages:
|
|||
dependency: transitive
|
||||
description:
|
||||
name: matcher
|
||||
sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2
|
||||
sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.12.17"
|
||||
version: "0.12.19"
|
||||
material_color_utilities:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: material_color_utilities
|
||||
sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec
|
||||
sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.11.1"
|
||||
version: "0.13.0"
|
||||
meta:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: meta
|
||||
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
|
||||
sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.17.0"
|
||||
version: "1.18.0"
|
||||
mime:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -752,10 +752,10 @@ packages:
|
|||
dependency: transitive
|
||||
description:
|
||||
name: test_api
|
||||
sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55
|
||||
sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.7"
|
||||
version: "0.7.11"
|
||||
typed_data:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue