This commit is contained in:
e32232340-Nadif 2026-04-19 07:39:04 +07:00
parent 828645da5b
commit 95b1759344
5 changed files with 345 additions and 108 deletions

View File

@ -0,0 +1,82 @@
class TimeSeriesMapper {
/// =========================
/// 📅 DAILY (24 JAM)
/// =========================
static List<double> toDaily<T>({
required List<T> data,
required DateTime Function(T) getTime,
required double Function(T) getValue,
}) {
final now = DateTime.now();
final slots = List<double>.filled(24, 0.0);
for (final item in data) {
final time = getTime(item);
if (_isSameDay(time, now)) {
slots[time.hour] = getValue(item);
}
}
return slots;
}
/// =========================
/// 📅 WEEKLY (7 HARI)
/// =========================
static List<double> toWeekly<T>({
required List<T> data,
required DateTime Function(T) getTime,
required double Function(T) getValue,
}) {
final now = DateTime.now();
final slots = List<double>.filled(7, 0.0);
DateTime startOfWeek =
now.subtract(Duration(days: now.weekday - 1));
startOfWeek = DateTime(
startOfWeek.year, startOfWeek.month, startOfWeek.day);
for (final item in data) {
final time = getTime(item);
if (time.isAfter(startOfWeek)) {
slots[time.weekday - 1] = getValue(item);
}
}
return slots;
}
/// =========================
/// 📅 MONTHLY
/// =========================
static List<double> toMonthly<T>({
required List<T> data,
required DateTime Function(T) getTime,
required double Function(T) getValue,
}) {
final now = DateTime.now();
final daysInMonth = DateTime(now.year, now.month + 1, 0).day;
final slots = List<double>.filled(daysInMonth, 0.0);
for (final item in data) {
final time = getTime(item);
if (time.month == now.month && time.year == now.year) {
slots[time.day - 1] = getValue(item);
}
}
return slots;
}
/// =========================
/// 🧠 HELPER
/// =========================
static bool _isSameDay(DateTime a, DateTime b) {
return a.year == b.year &&
a.month == b.month &&
a.day == b.day;
}
}

View File

@ -0,0 +1,140 @@
import 'dart:async';
import 'package:bloc/bloc.dart';
import 'package:equatable/equatable.dart';
import 'package:monitoring_repository/monitoring_repository.dart';
import '../../../core/utils/time_series_mapper.dart';
part 'evaporasi_event.dart';
part 'evaporasi_state.dart';
class EvaporasiBloc extends Bloc<EvaporasiEvent, EvaporasiState> {
final MonitoringRepository _repository;
StreamSubscription<Evaporasi>? _subscription;
EvaporasiBloc({required MonitoringRepository repository})
: _repository = repository,
super(const EvaporasiState()) {
on<WatchEvaporasiStarted>(_onStarted);
on<_EvaporasiRealtimeUpdated>(_onRealtimeUpdated);
on<EvaporasiPeriodChanged>(_onPeriodChanged);
}
/// =========================
/// 🚀 START
/// =========================
Future<void> _onStarted(
WatchEvaporasiStarted event,
Emitter<EvaporasiState> emit,
) async {
emit(state.copyWith(isLoading: true));
final history = await _repository.getSensorHistory(
'evaporasi/history',
(json) => Evaporasi.fromJson(json),
);
final dailyGraph = TimeSeriesMapper.toDaily(
data: history,
getTime: (e) => e.timestamp,
getValue: (e) => e.value, // sesuaikan nama field
);
emit(state.copyWith(
history: history,
dailyValues: dailyGraph,
isLoading: false,
));
await _subscription?.cancel();
_subscription = _repository
.getSensorStream(
'evaporasi/realtime',
(json) => Evaporasi.fromJson(json),
)
.listen((data) {
add(_EvaporasiRealtimeUpdated(data));
});
}
/// =========================
/// REALTIME
/// =========================
void _onRealtimeUpdated(
_EvaporasiRealtimeUpdated event,
Emitter<EvaporasiState> emit,
) {
final updated = List<double>.from(state.dailyValues);
final index = DateTime.now().hour;
if (index < updated.length) {
updated[index] = event.data.value; // sesuaikan field
}
emit(state.copyWith(
currentValue: event.data.value,
dailyValues: updated,
));
}
/// =========================
/// 📊 PERIOD
/// =========================
Future<void> _onPeriodChanged(
EvaporasiPeriodChanged event,
Emitter<EvaporasiState> emit,
) async {
emit(state.copyWith(isLoading: true, selectedPeriod: event.period));
final history = state.history;
List<double> updated;
if (event.period == "Minggu Ini") {
updated = TimeSeriesMapper.toWeekly(
data: history,
getTime: (e) => e.timestamp,
getValue: (e) => e.value,
);
} else if (event.period == "Bulan Ini") {
updated = TimeSeriesMapper.toMonthly(
data: history,
getTime: (e) => e.timestamp,
getValue: (e) => e.value,
);
} else {
updated = TimeSeriesMapper.toDaily(
data: history,
getTime: (e) => e.timestamp,
getValue: (e) => e.value,
);
}
emit(state.copyWith(
dailyValues: updated,
isLoading: false,
));
}
@override
Future<void> close() async {
await _subscription?.cancel();
return super.close();
}
}
/// INTERNAL EVENT
class _EvaporasiRealtimeUpdated extends EvaporasiEvent {
final Evaporasi data;
const _EvaporasiRealtimeUpdated(this.data);
@override
List<Object> get props => [data];
}

View File

@ -3,138 +3,149 @@ import 'package:bloc/bloc.dart';
import 'package:equatable/equatable.dart'; import 'package:equatable/equatable.dart';
import 'package:monitoring_repository/monitoring_repository.dart'; import 'package:monitoring_repository/monitoring_repository.dart';
import 'package:bloc_concurrency/bloc_concurrency.dart'; import 'package:bloc_concurrency/bloc_concurrency.dart';
import '../../../../core/utils/time_series_mapper.dart';
part 'wind_speed_event.dart'; part 'wind_speed_event.dart';
part 'wind_speed_state.dart'; part 'wind_speed_state.dart';
class WindSpeedBloc extends Bloc<WindSpeedEvent, WindSpeedState> { class WindSpeedBloc extends Bloc<WindSpeedEvent, WindSpeedState> {
final MonitoringRepository _repository; final MonitoringRepository _repository;
StreamSubscription<MyWindSpeed>? _subscription;
WindSpeedBloc({required MonitoringRepository repository}) WindSpeedBloc({required MonitoringRepository repository})
: _repository = repository, : _repository = repository,
super(const WindSpeedState()) { super(const WindSpeedState()) {
// Handler saat aplikasi minta mulai monitoring
on<WatchWindSpeedStarted>(
(event, emit) async {
emit(state.copyWith(isLoading: true));
// 1. Ambil semua history /// 🔥 START MONITORING
final history = await _repository.getSensorHistory( on<WatchWindSpeedStarted>(_onStarted, transformer: restartable());
'anemometer/history', (json) => MyWindSpeed.fromJson(json));
// 2. Petakan ke 24 jam (Harian) /// 🔥 REALTIME UPDATE
final List<double> dailyGraph = _mapHistoryToDaily(history); on<_WindSpeedRealtimeUpdated>(_onRealtimeUpdated);
emit(state.copyWith( /// 🔥 CHANGE PERIOD
dailySpeeds: dailyGraph, on<WindSpeedPeriodChanged>(_onPeriodChanged);
isLoading: false, }
));
// 2. Monitoring Real-time /// =========================
await emit.forEach<MyWindSpeed>( /// 🚀 START
_repository.getSensorStream( /// =========================
'anemometer/realtime', (json) => MyWindSpeed.fromJson(json)), Future<void> _onStarted(
onData: (data) { WatchWindSpeedStarted event,
// 1. Ambil list kecepatan yang ada saat ini di state Emitter<WindSpeedState> emit,
final updatedSpeeds = List<double>.from(state.dailySpeeds); ) async {
// 2. Tentukan di index mana data ini harus masuk (misal berdasarkan menit saat ini) emit(state.copyWith(isLoading: true));
final int index = DateTime.now().minute;
// 3. Update nilai di index tersebut /// 1. Ambil history SEKALI
if (index < updatedSpeeds.length) { final history = await _repository.getSensorHistory(
updatedSpeeds[index] = data.speed; 'anemometer/history',
} (json) => MyWindSpeed.fromJson(json),
return state.copyWith(
currentSpeed: data.speed,
dailySpeeds: updatedSpeeds,
isLoading: false,
);
},
onError: (error, stackTrace) => state.copyWith(isLoading: false),
); // emit forEach
},
transformer: restartable(),
); );
on<WindSpeedPeriodChanged>((event, emit) async { /// 2. Mapping default (harian)
emit(state.copyWith(selectedPeriod: event.period)); final dailyGraph = TimeSeriesMapper.toDaily(
// Ambil ulang history dari database data: history,
final history = await _repository.getSensorHistory( getTime: (e) => e.timestamp,
'anemometer/history', (json) => MyWindSpeed.fromJson(json)); getValue: (e) => e.speed,
List<double> updatedGraph; );
// Pilih mapper berdasarkan pilihan user emit(state.copyWith(
if (event.period == "Minggu Ini") { history: history,
updatedGraph = _mapHistoryToWeekly(history); dailySpeeds: dailyGraph,
} else if (event.period == "Bulan Ini") { isLoading: false,
updatedGraph = _mapHistoryToMonthly(history); ));
} else {
updatedGraph = _mapHistoryToDaily(history);
}
emit(state.copyWith( /// 3. Start realtime stream (manual subscription)
dailySpeeds: updatedGraph, await _subscription?.cancel();
isLoading: false,
)); _subscription = _repository
.getSensorStream(
'anemometer/realtime',
(json) => MyWindSpeed.fromJson(json),
)
.listen((data) {
add(_WindSpeedRealtimeUpdated(data));
}); });
} }
/// =========================
/// REALTIME UPDATE
/// =========================
void _onRealtimeUpdated(
_WindSpeedRealtimeUpdated event,
Emitter<WindSpeedState> emit,
) {
final updatedSpeeds = List<double>.from(state.dailySpeeds);
/// 🔥 FIX: pakai JAM bukan menit
final int index = DateTime.now().hour;
if (index < updatedSpeeds.length) {
updatedSpeeds[index] = event.data.speed;
}
emit(state.copyWith(
currentSpeed: event.data.speed,
dailySpeeds: updatedSpeeds,
));
}
/// =========================
/// 📊 CHANGE PERIOD
/// =========================
Future<void> _onPeriodChanged(
WindSpeedPeriodChanged event,
Emitter<WindSpeedState> emit,
) async {
emit(state.copyWith(isLoading: true, selectedPeriod: event.period));
final history = state.history;
List<double> updatedGraph;
if (event.period == "Minggu Ini") {
updatedGraph = TimeSeriesMapper.toWeekly(
data: history,
getTime: (e) => e.timestamp,
getValue: (e) => e.speed,
);
} else if (event.period == "Bulan Ini") {
updatedGraph = TimeSeriesMapper.toMonthly(
data: history,
getTime: (e) => e.timestamp,
getValue: (e) => e.speed,
);
} else {
updatedGraph = TimeSeriesMapper.toDaily(
data: history,
getTime: (e) => e.timestamp,
getValue: (e) => e.speed,
);
}
emit(state.copyWith(
dailySpeeds: updatedGraph,
isLoading: false,
));
}
@override @override
Future<void> close() => super.close(); Future<void> close() async {
} await _subscription?.cancel();
return super.close();
List<double> _mapHistoryToDaily(List<MyWindSpeed> history) {
// Buat list 24 angka nol (representasi jam 00:00 - 23:00)
List<double> slots = List.generate(24, (_) => 0.0); // mode asli
// Kita buat 60 slot (representasi menit 0-59) agar tiap menit kelihatan titik baru
// List<double> slots = List.generate(60, (_) => 0.0); // mode percobaan
for (var item in history) {
// Ubah timestamp UTC ke jam lokal
DateTime date = item.timestamp; // sudah dateTime dari model
// Jika data ini adalah data hari ini, masukkan ke slot jamnya
if (date.day == DateTime.now().day) {
slots[date.hour] = item.speed;
} // mode asli
// pakai menit untuk percobaan (date.minute) sebagai index supaya kelihatan pergerakannya
// if (date.day == DateTime.now().day) {
// slots[date.minute] = item.speed;
// } // mode percobaan/testing
} }
return slots;
} }
// Mapper Mingguan (7 Slot: Senin - Minggu) /// =========================
List<double> _mapHistoryToWeekly(List<MyWindSpeed> history) { /// 🔒 INTERNAL EVENT (PRIVATE)
List<double> slots = List.generate(7, (_) => 0.0); /// =========================
final now = DateTime.now(); class _WindSpeedRealtimeUpdated extends WindSpeedEvent {
final MyWindSpeed data;
// Mencari awal minggu ini (Senin) const _WindSpeedRealtimeUpdated(this.data);
DateTime startOfWeek = now.subtract(Duration(days: now.weekday - 1));
startOfWeek = DateTime(startOfWeek.year, startOfWeek.month, startOfWeek.day);
for (var item in history) { @override
if (item.timestamp.isAfter(startOfWeek)) { List<Object> get props => [data];
// index 0 = Senin, dst.
slots[item.timestamp.weekday - 1] = item.speed;
}
}
return slots;
}
// Mapper Bulanan (Sesuai jumlah hari di bulan ini)
List<double> _mapHistoryToMonthly(List<MyWindSpeed> history) {
final now = DateTime.now();
final daysInMonth = DateTime(now.year, now.month + 1, 0).day;
List<double> slots = List.generate(daysInMonth, (_) => 0.0);
for (var item in history) {
if (item.timestamp.month == now.month && item.timestamp.year == now.year) {
// index 0 = Tanggal 1, dst.
slots[item.timestamp.day - 1] = item.speed;
}
}
return slots;
} }

View File

@ -5,12 +5,14 @@ class WindSpeedState extends Equatable {
final String selectedPeriod; final String selectedPeriod;
final List<double> dailySpeeds; // Untuk grafik final List<double> dailySpeeds; // Untuk grafik
final bool isLoading; final bool isLoading;
final List<MyWindSpeed> history;
const WindSpeedState({ const WindSpeedState({
this.currentSpeed = 0.0, this.currentSpeed = 0.0,
this.selectedPeriod = "Hari Ini", this.selectedPeriod = "Hari Ini",
this.dailySpeeds = const [], this.dailySpeeds = const [],
this.isLoading = true, this.isLoading = true,
this.history = const [],
}); });
WindSpeedState copyWith({ WindSpeedState copyWith({
@ -18,16 +20,18 @@ class WindSpeedState extends Equatable {
String? selectedPeriod, String? selectedPeriod,
List<double>? dailySpeeds, List<double>? dailySpeeds,
bool? isLoading, bool? isLoading,
List<MyWindSpeed>? history,
}) { }) {
return WindSpeedState( return WindSpeedState(
currentSpeed: currentSpeed ?? this.currentSpeed, currentSpeed: currentSpeed ?? this.currentSpeed,
selectedPeriod: selectedPeriod ?? this.selectedPeriod, selectedPeriod: selectedPeriod ?? this.selectedPeriod,
dailySpeeds: dailySpeeds ?? this.dailySpeeds, dailySpeeds: dailySpeeds ?? this.dailySpeeds,
isLoading: isLoading ?? this.isLoading, isLoading: isLoading ?? this.isLoading,
history: history ?? this.history,
); );
} }
@override @override
List<Object> get props => List<Object> get props =>
[currentSpeed, selectedPeriod, dailySpeeds, isLoading]; [currentSpeed, selectedPeriod, dailySpeeds, isLoading, history];
} }