From 62ab660aa1a6b3dadf861886bbad9c9c9ddc86f5 Mon Sep 17 00:00:00 2001 From: Mochamad ongki ramadani Date: Tue, 19 May 2026 14:59:43 +0700 Subject: [PATCH] terbaru --- TODO.md | 17 +- analyze_output.txt | Bin 13996 -> 17034 bytes lib/core/utils/time_series_mapper.dart | 202 +++++--- .../evaporasi/blocs/evaporasi_bloc.dart | 367 ++++++-------- .../evaporasi/blocs/evaporasi_event.dart | 45 +- .../evaporasi/blocs/evaporasi_state.dart | 124 ++--- .../evaporasi/views/evaporasi_screen.dart | 445 ++++------------ .../views/widgets/evaporasi_chart_widget.dart | 56 +-- .../views/widgets/evaporasi_date_picker.dart | 237 --------- .../views/widgets/evaporasi_history_list.dart | 476 ++++++++++++++++++ .../widgets/evaporasi_range_selector.dart | 206 ++++++++ output_flutter_run.txt | Bin 0 -> 370602 bytes .../lib/src/models/evaporasi.dart | 45 +- tool_check.txt | Bin 0 -> 14 bytes 14 files changed, 1213 insertions(+), 1007 deletions(-) delete mode 100644 lib/screens/monitoring/evaporasi/views/widgets/evaporasi_date_picker.dart create mode 100644 lib/screens/monitoring/evaporasi/views/widgets/evaporasi_history_list.dart create mode 100644 lib/screens/monitoring/evaporasi/views/widgets/evaporasi_range_selector.dart create mode 100644 output_flutter_run.txt create mode 100644 tool_check.txt diff --git a/TODO.md b/TODO.md index 238b31f..a1b67bd 100644 --- a/TODO.md +++ b/TODO.md @@ -1,16 +1,5 @@ -# TODO - Fix flutter analyze issues +- [x] Pindahkan komponen pemilihan tanggal (๐Ÿ“… + EvaporasiPeriodSelector) di Evaporasi agar tampil di bawah grafik. +- [x] Pastikan tidak ada perubahan logika state (hanya urutan widget/placement). +- [ ] Jalankan hot reload/build untuk verifikasi UI. -## Step 1: Fix evaporasi.dart parse conflict -- Remove leftover git conflict markers in packages/monitoring_repository/lib/src/models/evaporasi.dart -- Unify timestamp parsing logic into a single implementation -- Ensure `factory Evaporasi.fromJson` always returns a non-null `Evaporasi` - -## Step 2: Fix Google Sign-In import errors -- Investigate why `package:google_sign_in/google_sign_in.dart` is reported missing -- Update user_repository dependency versions if needed -- Run `flutter pub get` (from c:/flutter/klimatologi) and `flutter analyze` again - -## Step 3: Re-run analyze and address remaining warnings -- Run `flutter analyze` and fix any remaining compile errors -- Optionally clean up performance/deprecation warnings (const constructors, withOpacity deprecation, Share->SharePlus) diff --git a/analyze_output.txt b/analyze_output.txt index 2dead12352f4ced098cd1b7db8334b852b88c941..e81e193e43f651ce2f18d6e07933601fa46c75f3 100644 GIT binary patch delta 1415 zcmb_cO-~b16g?wG(MT#NZWQ`RjMD}vX-lEhXrcrIE;K3$F~&>^Org?tY8eGwIAP&J z$Cmt2;Kj^$&RNn;{gU8#R-8ANSpR?ztcDy{$icV!azW9?W7H zE)=S$qX`c!_}nj2(nJ{xSmC&2G;CB*!vmg+Mhgyi9yQ9Gt9ZoO=j@?@RoDh`m1~<) z#WO`rmzXYPvTD$B11dv<3J?9?(5O;riDzv@^waL)$x)P0qBys?%V)pD>v36!&wG9xI5LI;-DFYJo4r57+1|@mEXtu6ZuTy8 z>W}GDI+gr7?qZ#>xXgmX$jf+WTszFChk0hC#(jnq$|kphnIrOqPL9rmFVrz>;_m>m zWKM_^b{q6E^H@hsTc_tPv}KJ1N?7AK&y1B#0i+x|gzk~HL!u2@OV&Iz^{eE{$e(yc zNbMm>Kv}XW=-Y9`{ic7qBTKtrEFIfb^ap!7mghwt zZucFutX%lL|F(75crkt?aaTqDZD6fOY{RX>AuHJ1pNgau@)+Y-Wr(ZnU6-R`b9h`! zA7;2qF=R=KXvrv*&tR0AX>{QzE|Ib_J>Hw++I5rDOxv@`v zq9!s~PvZll!De~QNJe%81}g?b2E)ybN}-ICA86QcnlV6x%qK4ty*l}TLBeJ~T^qs4 z4W=T#p$vIIn97h0#Kl0E$WX*k$)Lbc3S_4O>3oJXAX&^%0u;{#s!Rv+Ky(REwuqqs zD4zq=QvwvrXULoU(JXrM4Q-*xTg0UTjTubA?l1=G&;{aBtTx4?m>rMXY=+{=_tokr Iv)C^H00qxX0{{R3 diff --git a/lib/core/utils/time_series_mapper.dart b/lib/core/utils/time_series_mapper.dart index 3d61bf4..8005c4a 100644 --- a/lib/core/utils/time_series_mapper.dart +++ b/lib/core/utils/time_series_mapper.dart @@ -1,55 +1,88 @@ +// lib/core/utils/time_series_mapper.dart + class TimeSeriesMapper { - /// ========================= - /// ๐Ÿ“… DAILY (24 JAM) - /// ========================= + // ============================================================ + // FILTER SPIKE + // ============================================================ + static List _filterSpike(List raw) { + final valid = raw.where((v) => v > 0).toList()..sort(); + if (valid.isEmpty) return raw; + final median = valid[valid.length ~/ 2]; + if (median <= 0) return raw; + return raw.map((v) => (v > 0 && v > median * 3) ? -1.0 : v).toList(); + } + + // ============================================================ + // INTERPOLASI GAP + // ============================================================ + static List _interpolate(List raw) { + final result = List.from(raw); + final n = result.length; + for (int i = 0; i < n; i++) { + if (result[i] >= 0) continue; + double? prev; int prevIdx = -1; + for (int j = i - 1; j >= 0; j--) { + if (result[j] >= 0) { prev = result[j]; prevIdx = j; break; } + } + double? next; int nextIdx = -1; + for (int j = i + 1; j < n; j++) { + if (result[j] >= 0) { next = result[j]; nextIdx = j; break; } + } + if (prev != null && next != null) { + final t = (i - prevIdx) / (nextIdx - prevIdx); + result[i] = prev + t * (next - prev); + } else if (prev != null) { + result[i] = prev; + } else if (next != null) { + result[i] = next; + } else { + result[i] = 0.0; + } + } + return result; + } + + // ============================================================ + // DAILY (24 JAM) + // ============================================================ static List toDaily({ required List data, required DateTime Function(T) getTime, required double Function(T) getValue, }) { final now = DateTime.now(); - final sums = List.filled(24, 0.0); final counts = List.filled(24, 0); - for (final item in data) { final time = getTime(item); - if (_isSameDay(time, now)) { - final hour = time.toLocal().hour; // โœ… FIX: pastikan pakai local hour + final hour = time.toLocal().hour; if (hour >= 0 && hour < 24) { sums[hour] += getValue(item); counts[hour]++; } } } - - return List.generate(24, (i) { - if (counts[i] == 0) return 0; - return sums[i] / counts[i]; - }); + final raw = List.generate(24, (i) => + counts[i] == 0 ? -1.0 : sums[i] / counts[i]); + return _interpolate(_filterSpike(raw)); } - /// ========================= - /// ๐Ÿ“… WEEKLY (7 HARI) - /// ========================= + // ============================================================ + // WEEKLY + // ============================================================ static List toWeekly({ required List data, required DateTime Function(T) getTime, required double Function(T) getValue, }) { final now = DateTime.now(); - final sums = List.filled(7, 0.0); final counts = List.filled(7, 0); - DateTime startOfWeek = now.subtract(Duration(days: now.weekday - 1)); - startOfWeek = - DateTime(startOfWeek.year, startOfWeek.month, startOfWeek.day); - + startOfWeek = DateTime(startOfWeek.year, startOfWeek.month, startOfWeek.day); for (final item in data) { - final time = getTime(item).toLocal(); // โœ… FIX: konversi ke local - + final time = getTime(item).toLocal(); if (!time.isBefore(startOfWeek)) { final index = time.weekday - 1; if (index >= 0 && index < 7) { @@ -58,16 +91,14 @@ class TimeSeriesMapper { } } } - - return List.generate(7, (i) { - if (counts[i] == 0) return 0; - return sums[i] / counts[i]; - }); + final raw = List.generate(7, (i) => + counts[i] == 0 ? -1.0 : sums[i] / counts[i]); + return _interpolate(_filterSpike(raw)); } - /// ========================= - /// ๐Ÿ“… MONTHLY - /// ========================= + // ============================================================ + // MONTHLY + // ============================================================ static List toMonthly({ required List data, required DateTime Function(T) getTime, @@ -75,13 +106,10 @@ class TimeSeriesMapper { }) { final now = DateTime.now(); final daysInMonth = DateTime(now.year, now.month + 1, 0).day; - final sums = List.filled(daysInMonth, 0.0); final counts = List.filled(daysInMonth, 0); - for (final item in data) { - final time = getTime(item).toLocal(); // โœ… FIX: konversi ke local - + final time = getTime(item).toLocal(); if (time.month == now.month && time.year == now.year) { final index = time.day - 1; if (index >= 0 && index < daysInMonth) { @@ -90,16 +118,14 @@ class TimeSeriesMapper { } } } - - return List.generate(daysInMonth, (i) { - if (counts[i] == 0) return 0; - return sums[i] / counts[i]; - }); + final raw = List.generate(daysInMonth, (i) => + counts[i] == 0 ? -1.0 : sums[i] / counts[i]); + return _interpolate(_filterSpike(raw)); } - /// ========================= - /// ๐Ÿ“… SPECIFIC DATE (24 JAM - TANGGAL KHUSUS) - /// ========================= + // ============================================================ + // SPECIFIC DATE (24 JAM) + // ============================================================ static List toSpecificDate({ required List data, required DateTime Function(T) getTime, @@ -108,53 +134,101 @@ class TimeSeriesMapper { }) { final sums = List.filled(24, 0.0); final counts = List.filled(24, 0); - for (final item in data) { final time = getTime(item); - if (_isSameDay(time, targetDate)) { - final hour = time.toLocal().hour; // โœ… FIX: pastikan pakai local hour + final hour = time.toLocal().hour; if (hour >= 0 && hour < 24) { sums[hour] += getValue(item); counts[hour]++; } } } - - return List.generate(24, (i) { - if (counts[i] == 0) return 0; - return sums[i] / counts[i]; - }); + final raw = List.generate(24, (i) => + counts[i] == 0 ? -1.0 : sums[i] / counts[i]); + return _interpolate(_filterSpike(raw)); } - /// ========================= - /// ๐Ÿง  HELPER โ€” Bandingkan tanggal secara LOCAL (bukan UTC) - /// โœ… FIX: Firebase datetime "2026-05-14 01:25:25" di-parse sebagai local time, - /// jadi perbandingan harus pakai local time juga agar tidak mismatch timezone - /// ========================= + // ============================================================ + // DATE RANGE โ€” rentang tanggal bebas, agregasi per hari + // Return: values (satu titik per hari) + labels (dd/MM atau dd MMM) + // ============================================================ + static ({List values, List labels}) toDateRange({ + required List data, + required DateTime Function(T) getTime, + required double Function(T) getValue, + required DateTime startDate, + required DateTime endDate, + }) { + final start = DateTime(startDate.year, startDate.month, startDate.day); + final end = DateTime(endDate.year, endDate.month, endDate.day); + + // Buat list semua hari dalam rentang + final days = []; + DateTime cur = start; + while (!cur.isAfter(end)) { + days.add(cur); + cur = cur.add(const Duration(days: 1)); + } + + if (days.isEmpty) return (values: [], labels: []); + + final sums = List.filled(days.length, 0.0); + final counts = List.filled(days.length, 0); + + for (final item in data) { + final time = getTime(item).toLocal(); + final dayOnly = DateTime(time.year, time.month, time.day); + for (int i = 0; i < days.length; i++) { + if (dayOnly == days[i]) { + sums[i] += getValue(item); + counts[i]++; + break; + } + } + } + + final raw = List.generate(days.length, (i) => + counts[i] == 0 ? -1.0 : sums[i] / counts[i]); + + final values = _interpolate(_filterSpike(raw)); + + // Label: "dd MMM" jika <= 14 hari, "dd/MM" jika lebih + final labels = days.map((d) { + if (days.length <= 14) { + return '${d.day} ${_bulan(d.month)}'; + } + return '${d.day}/${d.month}'; + }).toList(); + + return (values: values, labels: labels); + } + + static String _bulan(int m) { + const b = ['', 'Jan', 'Feb', 'Mar', 'Apr', 'Mei', 'Jun', + 'Jul', 'Agu', 'Sep', 'Okt', 'Nov', 'Des']; + return b[m]; + } + + // ============================================================ + // HELPER + // ============================================================ static bool _isSameDay(DateTime a, DateTime b) { final al = a.toLocal(); final bl = b.toLocal(); return al.year == bl.year && al.month == bl.month && al.day == bl.day; } - /// ========================= - /// ๐Ÿ“ˆ SMOOTH (moving average 3 titik) - /// ========================= static List smooth(List data) { if (data.length < 3) return data; - - final List result = []; - + final result = []; for (int i = 0; i < data.length; i++) { if (i == 0 || i == data.length - 1) { result.add(data[i]); } else { - final avg = (data[i - 1] + data[i] + data[i + 1]) / 3; - result.add(avg); + result.add((data[i - 1] + data[i] + data[i + 1]) / 3); } } - return result; } } \ No newline at end of file diff --git a/lib/screens/monitoring/evaporasi/blocs/evaporasi_bloc.dart b/lib/screens/monitoring/evaporasi/blocs/evaporasi_bloc.dart index fab84c6..eeaacd6 100644 --- a/lib/screens/monitoring/evaporasi/blocs/evaporasi_bloc.dart +++ b/lib/screens/monitoring/evaporasi/blocs/evaporasi_bloc.dart @@ -1,3 +1,5 @@ +// lib/screens/monitoring/evaporasi/blocs/evaporasi_bloc.dart + import 'dart:async'; import 'package:bloc/bloc.dart'; @@ -20,14 +22,16 @@ class EvaporasiBloc extends Bloc { required NotificationBloc notificationBloc, }) : _repository = repository, _notificationBloc = notificationBloc, - super(const EvaporasiState()) { + super(EvaporasiState()) { on(_onStarted); on<_EvaporasiRealtimeUpdated>(_onRealtimeUpdated); - on(_onPeriodChanged); - on(_onDateSelected); - on(_onViewModeChanged); + on(_onDateRangeChanged); + on(_onDateFilterChanged); } + // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• + // START + // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• Future _onStarted( WatchEvaporasiStarted event, Emitter emit, @@ -39,249 +43,247 @@ class EvaporasiBloc extends Bloc { 'Monitoring/History', (json) => Evaporasi.fromJson(json), ), - ) - ..sort((a, b) => a.timestamp.compareTo(b.timestamp)); + )..sort((a, b) => a.timestamp.compareTo(b.timestamp)); - final listData = List.from(history); + final now = DateTime.now(); - final dailyGraph = TimeSeriesMapper.toDaily( + // Default: tampilkan hari ini (per jam) + final dailyEvap = TimeSeriesMapper.toDaily( data: history, getTime: (e) => e.timestamp, getValue: (e) => e.evaporasi, ); - - final dailyTempGraph = TimeSeriesMapper.toDaily( - data: history, - getTime: (e) => e.timestamp, - getValue: (e) => e.suhu, - ); - - final weeklyGraph = TimeSeriesMapper.toWeekly( - data: history, - getTime: (e) => e.timestamp, - getValue: (e) => e.evaporasi, - ); - - final monthlyGraph = TimeSeriesMapper.toMonthly( - data: history, - getTime: (e) => e.timestamp, - getValue: (e) => e.evaporasi, - ); - - final weeklyTemp = TimeSeriesMapper.toWeekly( - data: history, - getTime: (e) => e.timestamp, - getValue: (e) => e.suhu, - ); - - final monthlyTemp = TimeSeriesMapper.toMonthly( + final dailyTemp = TimeSeriesMapper.toDaily( data: history, getTime: (e) => e.timestamp, getValue: (e) => e.suhu, ); + final labels = List.generate( + 24, (i) => '${i.toString().padLeft(2, '0')}:00'); final lastValue = history.isNotEmpty ? history.last.evaporasi : 0.0; - final lastWaterLevel = history.isNotEmpty ? history.last.tinggiAir : 0.0; - final lastTemperature = history.isNotEmpty ? history.last.suhu : 0.0; + final lastWater = history.isNotEmpty ? history.last.tinggiAir : 0.0; + final lastTemp = history.isNotEmpty ? history.last.suhu : 0.0; - final (status, rain) = _computeWeatherStatus(lastValue); - _emitEvaporasiAlert(status, rain, lastValue); + final (status, willRain) = _computeStatus(lastValue); + _emitAlert(status, willRain, lastValue); emit(state.copyWith( history: history, - listData: listData, + filteredHistory: history, currentValue: lastValue, - waterLevel: lastWaterLevel, - temperature: lastTemperature, - dailyValues: dailyGraph, - dailyTemperatures: dailyTempGraph, - weeklyValues: weeklyGraph, - monthlyValues: monthlyGraph, - weeklyTemperatures: weeklyTemp, - monthlyTemperatures: monthlyTemp, - chartLabels: _buildChartLabels(period: 'Hari Ini'), + waterLevel: lastWater, + temperature: lastTemp, + startDate: DateTime(now.year, now.month, now.day), + endDate: DateTime(now.year, now.month, now.day), + chartValues: dailyEvap, + chartTemperatures: dailyTemp, + chartLabels: labels, weatherStatus: status, - willRain: rain, + willRain: willRain, currentData: history.isNotEmpty ? history.last : null, - viewMode: EvaporasiViewMode.period, - selectedDate: null, isLoading: false, )); await _subscription?.cancel(); _subscription = _repository - .getSensorStream('Monitoring/History', _latestHistoryEntry) + .getSensorStream( + 'Monitoring', + (json) { + final f = Map.from(json)..remove('History'); + return Evaporasi.fromJson(f); + }, + ) .listen((data) => add(_EvaporasiRealtimeUpdated(data))); } - Evaporasi _latestHistoryEntry(Map json) { - if (json.isEmpty) return Evaporasi.empty; - - final entries = json.values - .whereType>() - .map((item) => Evaporasi.fromJson(item)) - .toList(); - - if (entries.isEmpty) return Evaporasi.empty; - - entries.sort((a, b) => a.timestamp.compareTo(b.timestamp)); - return entries.last; - } - + // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• + // REALTIME UPDATE + // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• void _onRealtimeUpdated( _EvaporasiRealtimeUpdated event, Emitter emit, ) { + if (event.data.timestamp.millisecondsSinceEpoch == 0) return; + final updatedHistory = List.from(state.history); - - final duplicateIndex = updatedHistory.indexWhere( - (item) => item.timestamp.toUtc() == event.data.timestamp.toUtc(), + final dupIdx = updatedHistory.indexWhere( + (e) => e.timestamp.toUtc() == event.data.timestamp.toUtc(), ); - - if (duplicateIndex >= 0) { - updatedHistory[duplicateIndex] = event.data; + if (dupIdx >= 0) { + updatedHistory[dupIdx] = event.data; } else { updatedHistory.add(event.data); } - updatedHistory.sort((a, b) => a.timestamp.compareTo(b.timestamp)); - // Update bucket untuk tampilan chart harian (index hour) - final updated = List.from(state.dailyValues); - final updatedTemp = List.from(state.dailyTemperatures); + // Update chart jika sedang tampil hari ini per jam + List updatedChart = state.chartValues; + List updatedTemp = state.chartTemperatures; - final eventTime = event.data.timestamp; - final now = DateTime.now(); + if (state.isSingleDay) { + final eventTime = event.data.timestamp.toLocal(); + final now = DateTime.now(); + final isToday = eventTime.year == now.year && + eventTime.month == now.month && + eventTime.day == now.day; - final isSameDayUtc = eventTime.toUtc().year == now.toUtc().year && - eventTime.toUtc().month == now.toUtc().month && - eventTime.toUtc().day == now.toUtc().day; - - final isDuplicate = duplicateIndex >= 0; - if (isSameDayUtc && !isDuplicate) { - final index = eventTime.hour; - if (index >= 0 && index < updated.length) { - updated[index] = event.data.evaporasi; - updatedTemp[index] = event.data.suhu; + if (isToday && dupIdx < 0) { + updatedChart = List.from(state.chartValues); + updatedTemp = List.from(state.chartTemperatures); + final hour = eventTime.hour; + if (hour >= 0 && hour < 24) { + updatedChart[hour] = event.data.evaporasi; + updatedTemp[hour] = event.data.suhu; + } } } - final (status, rain) = _computeWeatherStatus(event.data.evaporasi); - _emitEvaporasiAlert(status, rain, event.data.evaporasi); + final (status, willRain) = _computeStatus(event.data.evaporasi); + _emitAlert(status, willRain, event.data.evaporasi); emit(state.copyWith( history: updatedHistory, - listData: updatedHistory, + filteredHistory: state.selectedDateFilter != null + ? updatedHistory.where((e) => + e.timestamp.year == state.selectedDateFilter!.year && + e.timestamp.month == state.selectedDateFilter!.month && + e.timestamp.day == state.selectedDateFilter!.day).toList() + : updatedHistory, currentValue: event.data.evaporasi, temperature: event.data.suhu, waterLevel: event.data.tinggiAir, - dailyValues: updated, - dailyTemperatures: updatedTemp, + chartValues: updatedChart, + chartTemperatures: updatedTemp, weatherStatus: status, - willRain: rain, + willRain: willRain, currentData: event.data, )); } - Future _onPeriodChanged( - EvaporasiPeriodChanged event, + // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• + // DATE RANGE CHANGED + // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• + Future _onDateRangeChanged( + EvaporasiDateRangeChanged event, Emitter emit, ) async { - emit(state.copyWith(isLoading: true, selectedPeriod: event.period)); + emit(state.copyWith(isLoading: true)); final history = state.history; + final start = event.startDate; + final end = event.endDate; - List updated; - List updatedTemp; + final isSingle = _isSameDay(start, end); - if (event.period == 'Minggu Ini') { - updated = TimeSeriesMapper.toWeekly( + List values; + List temps; + List labels; + + if (isSingle) { + // 1 hari โ†’ per jam + values = TimeSeriesMapper.toSpecificDate( data: history, getTime: (e) => e.timestamp, getValue: (e) => e.evaporasi, + targetDate: start, ); - updatedTemp = TimeSeriesMapper.toWeekly( - data: history, - getTime: (e) => e.timestamp, - getValue: (e) => e.suhu, - ); - } else if (event.period == 'Bulan Ini') { - updated = TimeSeriesMapper.toMonthly( - data: history, - getTime: (e) => e.timestamp, - getValue: (e) => e.evaporasi, - ); - updatedTemp = TimeSeriesMapper.toMonthly( + temps = TimeSeriesMapper.toSpecificDate( data: history, getTime: (e) => e.timestamp, getValue: (e) => e.suhu, + targetDate: start, ); + labels = List.generate(24, (i) => '${i.toString().padLeft(2, '0')}:00'); } else { - updated = TimeSeriesMapper.toDaily( + // Range โ†’ per hari + final evapResult = TimeSeriesMapper.toDateRange( data: history, getTime: (e) => e.timestamp, getValue: (e) => e.evaporasi, + startDate: start, + endDate: end, ); - updatedTemp = TimeSeriesMapper.toDaily( + final tempResult = TimeSeriesMapper.toDateRange( data: history, getTime: (e) => e.timestamp, getValue: (e) => e.suhu, + startDate: start, + endDate: end, ); + values = evapResult.values; + temps = tempResult.values; + labels = evapResult.labels; } emit(state.copyWith( - dailyValues: updated, - dailyTemperatures: updatedTemp, - chartLabels: _buildChartLabels(period: event.period), - viewMode: EvaporasiViewMode.period, - clearSelectedDate: true, + startDate: start, + endDate: end, + chartValues: values, + chartTemperatures: temps, + chartLabels: labels, isLoading: false, )); } - Future _onDateSelected( - EvaporasiDateSelected event, + // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• + // DATE FILTER (LIST) + // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• + void _onDateFilterChanged( + EvaporasiDateFilterChanged event, Emitter emit, - ) async { - emit(state.copyWith( - isLoading: true, - selectedDate: event.date, - viewMode: EvaporasiViewMode.customDate, - )); - - final history = state.history; - - final updated = TimeSeriesMapper.toSpecificDate( - data: history, - getTime: (e) => e.timestamp, - getValue: (e) => e.evaporasi, - targetDate: event.date, - ); - - final updatedTemp = TimeSeriesMapper.toSpecificDate( - data: history, - getTime: (e) => e.timestamp, - getValue: (e) => e.suhu, - targetDate: event.date, - ); + ) { + final date = event.date; + if (date == null) { + emit(state.copyWith( + filteredHistory: state.history, + clearSelectedDateFilter: true, + )); + return; + } + final filtered = state.history.where((item) => + item.timestamp.year == date.year && + item.timestamp.month == date.month && + item.timestamp.day == date.day).toList(); emit(state.copyWith( - dailyValues: updated, - dailyTemperatures: updatedTemp, - chartLabels: _buildChartLabels(period: 'Tanggal Khusus'), - isLoading: false, + filteredHistory: filtered, + selectedDateFilter: date, )); } - Future _onViewModeChanged( - EvaporasiViewModeChanged event, - Emitter emit, - ) async { - if (event.mode == EvaporasiViewMode.period) { - add(EvaporasiPeriodChanged(state.selectedPeriod)); + // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• + // HELPERS + // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• + static bool _isSameDay(DateTime a, DateTime b) => + a.year == b.year && a.month == b.month && a.day == b.day; + + static (String, bool) _computeStatus(double v) { + if (v > 10.0) return ('Tinggi', true); + if (v >= 2.0) return ('Normal', false); + return ('Rendah', false); + } + + void _emitAlert(String status, bool willRain, double value) { + final AlertSeverity severity; + final String message; + if (status == 'Tinggi') { + severity = AlertSeverity.danger; + message = 'Evaporasi ${value.toStringAsFixed(1)} mm โ€” TINGGI'; + } else if (status == 'Normal') { + severity = AlertSeverity.warning; + message = 'Evaporasi ${value.toStringAsFixed(1)} mm โ€” Normal'; } else { - emit(state.copyWith(viewMode: event.mode)); + severity = AlertSeverity.info; + message = ''; } + _notificationBloc.add(SensorAlertAdded(SensorAlert( + sensorId: 'evaporasi', + sensorName: 'Evaporasi', + message: message, + severity: severity, + timestamp: DateTime.now(), + ))); } @override @@ -289,63 +291,12 @@ class EvaporasiBloc extends Bloc { await _subscription?.cancel(); return super.close(); } - - static (String status, bool willRain) _computeWeatherStatus(double value) { - if (value <= 5.0) return ('Baik', false); - if (value <= 10.0) return ('Sedang', true); - return ('Buruk', true); - } - - void _emitEvaporasiAlert(String status, bool willRain, double value) { - final AlertSeverity severity; - final String message; - - if (status == 'Buruk') { - severity = AlertSeverity.danger; - message = - 'Evaporasi ${value.toStringAsFixed(1)} mm โ€” status BURUK, potensi hujan tinggi'; - } else if (status == 'Sedang') { - severity = AlertSeverity.warning; - message = - 'Evaporasi ${value.toStringAsFixed(1)} mm โ€” status sedang, potensi hujan'; - } else { - severity = AlertSeverity.info; - message = ''; - } - - _notificationBloc.add(SensorAlertAdded( - SensorAlert( - sensorId: 'evaporasi', - sensorName: 'Evaporasi', - message: message, - severity: severity, - timestamp: DateTime.now(), - ), - )); - } - - List _buildChartLabels({required String period}) { - if (period == 'Minggu Ini') { - return const ['Sen', 'Sel', 'Rab', 'Kam', 'Jum', 'Sab', 'Min']; - } - - final now = DateTime.now(); - if (period == 'Bulan Ini') { - final daysInMonth = DateTime(now.year, now.month + 1, 0).day; - return List.generate(daysInMonth, (i) => '${i + 1}'); - } - - // Hari Ini / Tanggal Khusus => 24 jam - return List.generate(24, (i) => '${i.toString().padLeft(2, '0')}:00'); - } } class _EvaporasiRealtimeUpdated extends EvaporasiEvent { final Evaporasi data; - const _EvaporasiRealtimeUpdated(this.data); @override - List get props => [data]; -} - + List get props => [data]; +} \ No newline at end of file diff --git a/lib/screens/monitoring/evaporasi/blocs/evaporasi_event.dart b/lib/screens/monitoring/evaporasi/blocs/evaporasi_event.dart index cbf007d..ad26b3f 100644 --- a/lib/screens/monitoring/evaporasi/blocs/evaporasi_event.dart +++ b/lib/screens/monitoring/evaporasi/blocs/evaporasi_event.dart @@ -1,41 +1,38 @@ +// lib/screens/monitoring/evaporasi/blocs/evaporasi_event.dart part of 'evaporasi_bloc.dart'; abstract class EvaporasiEvent extends Equatable { const EvaporasiEvent(); @override - List get props => []; + List get props => []; } -/// ๐Ÿš€ START MONITORING +/// Mulai monitoring class WatchEvaporasiStarted extends EvaporasiEvent {} -/// ๐Ÿ“Š GANTI PERIODE (Harian / Mingguan / Bulanan) -class EvaporasiPeriodChanged extends EvaporasiEvent { - final String period; +/// Pilih rentang tanggal untuk grafik +/// startDate == endDate โ†’ tampilkan per jam (1 hari) +/// startDate != endDate โ†’ tampilkan per hari (range) +class EvaporasiDateRangeChanged extends EvaporasiEvent { + final DateTime startDate; + final DateTime endDate; - const EvaporasiPeriodChanged(this.period); + const EvaporasiDateRangeChanged({ + required this.startDate, + required this.endDate, + }); @override - List get props => [period]; + List get props => [startDate, endDate]; } -/// ๐Ÿ“… PILIH TANGGAL KHUSUS (Custom Date Picker) -class EvaporasiDateSelected extends EvaporasiEvent { - final DateTime date; - - const EvaporasiDateSelected(this.date); +/// Filter list history berdasarkan tanggal +/// Kirim date = null untuk reset +class EvaporasiDateFilterChanged extends EvaporasiEvent { + final DateTime? date; + const EvaporasiDateFilterChanged(this.date); @override - List get props => [date]; -} - -/// ๐Ÿ”„ KEMBALI KE MODE PERIOD -class EvaporasiViewModeChanged extends EvaporasiEvent { - final EvaporasiViewMode mode; - - const EvaporasiViewModeChanged(this.mode); - - @override - List get props => [mode]; -} + List get props => [date]; +} \ No newline at end of file diff --git a/lib/screens/monitoring/evaporasi/blocs/evaporasi_state.dart b/lib/screens/monitoring/evaporasi/blocs/evaporasi_state.dart index 1befed8..5a3486d 100644 --- a/lib/screens/monitoring/evaporasi/blocs/evaporasi_state.dart +++ b/lib/screens/monitoring/evaporasi/blocs/evaporasi_state.dart @@ -1,71 +1,64 @@ +// lib/screens/monitoring/evaporasi/blocs/evaporasi_state.dart part of 'evaporasi_bloc.dart'; -enum EvaporasiViewMode { period, customDate } - class EvaporasiState extends Equatable { final double currentValue; final double temperature; final double waterLevel; - final String selectedPeriod; - final DateTime? selectedDate; - final EvaporasiViewMode viewMode; - final List dailyValues; - final List weeklyValues; - final List monthlyValues; - - final List dailyTemperatures; - final List weeklyTemperatures; - final List monthlyTemperatures; + // Rentang tanggal aktif untuk grafik + final DateTime startDate; + final DateTime endDate; + // Data grafik + final List chartValues; + final List chartTemperatures; final List chartLabels; - final List listData; + + // Data list final List history; + final List filteredHistory; + + // Filter list + final DateTime? selectedDateFilter; + final String weatherStatus; final bool willRain; - final Evaporasi? currentData; // data realtime terbaru + final Evaporasi? currentData; final bool isLoading; - const EvaporasiState({ + EvaporasiState({ this.currentValue = 0.0, this.temperature = 0.0, this.waterLevel = 0.0, - this.selectedPeriod = 'Hari Ini', - this.selectedDate, - this.viewMode = EvaporasiViewMode.period, - this.dailyValues = const [], - this.weeklyValues = const [], - this.monthlyValues = const [], - this.dailyTemperatures = const [], - this.weeklyTemperatures = const [], - this.monthlyTemperatures = const [], + DateTime? startDate, + DateTime? endDate, + this.chartValues = const [], + this.chartTemperatures = const [], this.chartLabels = const [], - this.listData = const [], this.history = const [], - this.weatherStatus = 'Baik', + this.filteredHistory = const [], + this.selectedDateFilter, + this.weatherStatus = 'Rendah', this.willRain = false, this.currentData, this.isLoading = true, - }); + }) : startDate = startDate ?? DateTime.now(), + endDate = endDate ?? DateTime.now(); - // โœ… FIX: Tambah clearSelectedDate flag agar selectedDate bisa di-null-kan EvaporasiState copyWith({ double? currentValue, double? temperature, double? waterLevel, - String? selectedPeriod, - DateTime? selectedDate, - bool clearSelectedDate = false, // โœ… tambahan flag reset - EvaporasiViewMode? viewMode, - List? dailyValues, - List? weeklyValues, - List? monthlyValues, - List? dailyTemperatures, - List? weeklyTemperatures, - List? monthlyTemperatures, + DateTime? startDate, + DateTime? endDate, + List? chartValues, + List? chartTemperatures, List? chartLabels, - List? listData, List? history, + List? filteredHistory, + DateTime? selectedDateFilter, + bool clearSelectedDateFilter = false, String? weatherStatus, bool? willRain, Evaporasi? currentData, @@ -75,20 +68,16 @@ class EvaporasiState extends Equatable { currentValue: currentValue ?? this.currentValue, temperature: temperature ?? this.temperature, waterLevel: waterLevel ?? this.waterLevel, - selectedPeriod: selectedPeriod ?? this.selectedPeriod, - // โœ… FIX: jika clearSelectedDate=true, set null; jika selectedDate diberikan, pakai itu; - // jika tidak, pertahankan yang lama - selectedDate: clearSelectedDate ? null : (selectedDate ?? this.selectedDate), - viewMode: viewMode ?? this.viewMode, - dailyValues: dailyValues ?? this.dailyValues, - weeklyValues: weeklyValues ?? this.weeklyValues, - monthlyValues: monthlyValues ?? this.monthlyValues, - dailyTemperatures: dailyTemperatures ?? this.dailyTemperatures, - weeklyTemperatures: weeklyTemperatures ?? this.weeklyTemperatures, - monthlyTemperatures: monthlyTemperatures ?? this.monthlyTemperatures, + startDate: startDate ?? this.startDate, + endDate: endDate ?? this.endDate, + chartValues: chartValues ?? this.chartValues, + chartTemperatures: chartTemperatures ?? this.chartTemperatures, chartLabels: chartLabels ?? this.chartLabels, - listData: listData ?? this.listData, history: history ?? this.history, + filteredHistory: filteredHistory ?? this.filteredHistory, + selectedDateFilter: clearSelectedDateFilter + ? null + : (selectedDateFilter ?? this.selectedDateFilter), weatherStatus: weatherStatus ?? this.weatherStatus, willRain: willRain ?? this.willRain, currentData: currentData ?? this.currentData, @@ -96,26 +85,19 @@ class EvaporasiState extends Equatable { ); } + // 1 hari = tampil per jam, > 1 hari = tampil per hari + bool get isSingleDay { + final s = DateTime(startDate.year, startDate.month, startDate.day); + final e = DateTime(endDate.year, endDate.month, endDate.day); + return s == e; + } + @override List get props => [ - currentValue, - temperature, - waterLevel, - selectedPeriod, - selectedDate, - viewMode, - dailyValues, - weeklyValues, - monthlyValues, - dailyTemperatures, - weeklyTemperatures, - monthlyTemperatures, - chartLabels, - listData, - history, - weatherStatus, - willRain, - currentData, - isLoading, + currentValue, temperature, waterLevel, + startDate, endDate, + chartValues, chartTemperatures, chartLabels, + history, filteredHistory, selectedDateFilter, + weatherStatus, willRain, currentData, isLoading, ]; -} +} \ No newline at end of file diff --git a/lib/screens/monitoring/evaporasi/views/evaporasi_screen.dart b/lib/screens/monitoring/evaporasi/views/evaporasi_screen.dart index ef40d2b..464011e 100644 --- a/lib/screens/monitoring/evaporasi/views/evaporasi_screen.dart +++ b/lib/screens/monitoring/evaporasi/views/evaporasi_screen.dart @@ -1,3 +1,5 @@ +// lib/screens/monitoring/evaporasi/views/evaporasi_screen.dart + import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:intl/intl.dart'; @@ -5,8 +7,9 @@ import 'package:intl/intl.dart'; import '../blocs/evaporasi_bloc.dart'; import '../../shared/utils/pdf/pdf_export_service.dart'; import '../../shared/widgets/export_pdf_button.dart'; -import 'widgets/evaporasi_period_selector.dart'; import 'widgets/evaporasi_chart_widget.dart'; +import 'widgets/evaporasi_range_selector.dart'; +import 'widgets/evaporasi_history_list.dart'; class EvaporasiScreen extends StatefulWidget { const EvaporasiScreen({super.key}); @@ -22,7 +25,7 @@ class _EvaporasiScreenState extends State { backgroundColor: Colors.grey.shade100, appBar: AppBar( title: const Text( - "Evaporasi", + 'Evaporasi', style: TextStyle(fontWeight: FontWeight.bold), ), centerTitle: true, @@ -48,64 +51,70 @@ class _EvaporasiScreenState extends State { return SingleChildScrollView( padding: const EdgeInsets.all(20), child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ + // โ”€โ”€ Main Card โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ _mainCard(state), - const SizedBox(height: 25), - _infoRow(state), - const SizedBox(height: 25), - _statusCard(state), - const SizedBox(height: 25), - const Text( - "Tren Evaporasi & Suhu", - style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), - ), - const SizedBox(height: 15), - // Period selector with date picker - Builder( - builder: (context) { - final state = context.watch().state; - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (state.viewMode == EvaporasiViewMode.customDate && - state.selectedDate != null) - Padding( - padding: const EdgeInsets.only(bottom: 8), - child: Text( - "๐Ÿ“… ${_formatDateInfo(state.selectedDate!)}", - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w500, - color: Colors.blue.shade700, - ), - ), - ), - const EvaporasiPeriodSelector(), - ], - ); - }, - ), - const SizedBox(height: 15), - // Chart - Builder( - builder: (context) { - final state = context.watch().state; - return EvaporasiChartWidget( - dailyValues: state.dailyValues, - dailyTemperatures: state.dailyTemperatures, - period: state.viewMode == EvaporasiViewMode.customDate - ? "Tanggal Khusus" - : state.selectedPeriod, - chartLabels: state.chartLabels, - ); - }, - ), const SizedBox(height: 20), - // โœ… FIX: Gunakan BlocBuilder agar list reaktif terhadap perubahan state - BlocBuilder( - builder: (context, state) => _evaporasiList(state), + + // โ”€โ”€ Info Row โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + _infoRow(state), + const SizedBox(height: 20), + + // โ”€โ”€ Status Card โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + _statusCard(state), + const SizedBox(height: 20), + + // โ”€โ”€ Range Selector โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + const Text( + 'Tren Evaporasi & Suhu', + style: TextStyle( + fontSize: 18, fontWeight: FontWeight.bold), ), const SizedBox(height: 10), + const EvaporasiRangeSelector(), + const SizedBox(height: 12), + + // โ”€โ”€ Chart โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + BlocBuilder( + builder: (context, s) => EvaporasiChartWidget( + dailyValues: s.chartValues, + dailyTemperatures: s.chartTemperatures, + period: s.isSingleDay ? 'Hari Ini' : 'Range', + chartLabels: s.chartLabels, + ), + ), + const SizedBox(height: 20), + + // โ”€โ”€ Riwayat Data โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + BlocBuilder( + builder: (context, s) => EvaporasiHistoryList( + history: s.filteredHistory, + selectedDate: s.selectedDateFilter, + onPickDate: () async { + final picked = await showDatePicker( + context: context, + initialDate: DateTime.now(), + firstDate: DateTime(2024), + lastDate: DateTime.now(), + locale: const Locale('id', 'ID'), + ); + if (picked != null && context.mounted) { + context.read().add( + EvaporasiDateFilterChanged(picked), + ); + } + }, + onClearDate: () { + context.read().add( + const EvaporasiDateFilterChanged(null), + ); + }, + ), + ), + const SizedBox(height: 10), + + // โ”€โ”€ Export PDF โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ ExportPdfButton( onExport: () => PdfExportService.evaporasi( evaporasi: state.currentValue, @@ -115,6 +124,7 @@ class _EvaporasiScreenState extends State { historyData: historyMaps.isNotEmpty ? historyMaps : null, ), ), + const SizedBox(height: 20), ], ), ); @@ -123,9 +133,6 @@ class _EvaporasiScreenState extends State { ); } - // ========================= - // ๐Ÿ”ฅ MAIN CARD (EVAPORASI) - // ========================= Widget _mainCard(EvaporasiState state) { return Container( width: double.infinity, @@ -141,7 +148,7 @@ class _EvaporasiScreenState extends State { const Icon(Icons.water_drop, color: Colors.white, size: 45), const SizedBox(height: 10), Text( - state.currentValue.toStringAsFixed(1), + state.currentValue.toStringAsFixed(2), style: const TextStyle( fontSize: 70, fontWeight: FontWeight.bold, @@ -149,7 +156,7 @@ class _EvaporasiScreenState extends State { ), ), const Text( - "mm", + 'mm', style: TextStyle(color: Colors.white70, fontSize: 18), ), ], @@ -157,30 +164,22 @@ class _EvaporasiScreenState extends State { ); } - // ========================= - // ๐Ÿ“Š INFO KECIL (SUHU & AIR) - // ========================= Widget _infoRow(EvaporasiState state) { return Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - _miniCard( - "Suhu", - "${state.temperature.toStringAsFixed(1)} ยฐC", - Icons.thermostat, - Colors.orange, - ), - _miniCard( - "Tinggi Air", - "${state.waterLevel.toStringAsFixed(1)} cm", - Icons.water, - Colors.blue, - ), + _miniCard('Suhu Air', + '${state.temperature.toStringAsFixed(1)} ยฐC', + Icons.thermostat, Colors.orange), + _miniCard('Tinggi Air', + '${state.waterLevel.toStringAsFixed(1)} cm', + Icons.water, Colors.blue), ], ); } - Widget _miniCard(String title, String value, IconData icon, Color color) { + Widget _miniCard( + String title, String value, IconData icon, Color color) { return Container( width: 160, padding: const EdgeInsets.all(15), @@ -196,48 +195,43 @@ class _EvaporasiScreenState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(title, - style: const TextStyle(fontSize: 12, color: Colors.grey)), - Text(value, style: const TextStyle(fontWeight: FontWeight.bold)), + style: const TextStyle( + fontSize: 12, color: Colors.grey)), + Text(value, + style: const TextStyle(fontWeight: FontWeight.bold)), ], - ) + ), ], ), ); } - // ========================= - // ๐Ÿ“ˆ STATUS CARD - // ========================= Widget _statusCard(EvaporasiState state) { Color statusColor; IconData statusIcon; + String warningText; switch (state.weatherStatus) { - case "Sedang": + case 'Normal': statusColor = Colors.orange; statusIcon = Icons.warning_amber_rounded; + warningText = + 'Sedang โ€” evaporasi dalam batas normal, pantau kondisi.'; break; - case "Buruk": + case 'Tinggi': statusColor = Colors.red; statusIcon = Icons.error_outline; + warningText = + 'Tinggi โ€” evaporasi signifikan, berpotensi memengaruhi kondisi lingkungan.'; break; - case "Baik": + case 'Rendah': default: statusColor = Colors.green; statusIcon = Icons.check_circle_outline; + warningText = 'Rendah โ€” evaporasi stabil, risiko dampak rendah.'; break; } - final String warningText; - if (state.weatherStatus == 'Baik') { - warningText = 'Normal โ€” evaporasi stabil, risiko dampak rendah.'; - } else if (state.weatherStatus == 'Sedang') { - warningText = 'Sedang โ€” evaporasi mulai tinggi, pantau kondisi cuaca.'; - } else { - warningText = - 'Tinggi โ€” evaporasi signifikan, berpotensi memengaruhi kondisi lingkungan.'; - } - return Container( width: double.infinity, padding: const EdgeInsets.all(16), @@ -253,32 +247,25 @@ class _EvaporasiScreenState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Text( - "Status Cuaca", - style: TextStyle(fontSize: 12, color: Colors.grey), + const Text('Status Evaporasi', + style: + TextStyle(fontSize: 12, color: Colors.grey)), + const SizedBox(height: 4), + Text( + state.weatherStatus, + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + color: statusColor), ), const SizedBox(height: 4), Text( - state.weatherStatus == 'Baik' - ? 'Normal' - : state.weatherStatus == 'Sedang' - ? 'Sedang' - : 'Tinggi', + warningText, style: TextStyle( - fontSize: 20, - fontWeight: FontWeight.bold, - color: statusColor, - ), - ), - if (state.willRain) - Text( - warningText, - style: const TextStyle( fontSize: 12, - color: Colors.red, - fontWeight: FontWeight.w500, - ), - ), + color: statusColor.withValues(alpha: 0.8), + fontWeight: FontWeight.w500), + ), ], ), ), @@ -286,228 +273,4 @@ class _EvaporasiScreenState extends State { ), ); } - - // ========================= - // ๐Ÿงพ LIST DATA EVAPORASI โ€” โœ… FIXED FILTER - // ========================= - Widget _evaporasiList(EvaporasiState state) { - final now = DateTime.now(); - final today = DateTime(now.year, now.month, now.day); - - - // โœ… Filter list sesuai mode & periode yang aktif - List filteredData; - - if (state.viewMode == EvaporasiViewMode.customDate && - state.selectedDate != null) { - // Mode custom date: tampilkan hanya data di tanggal yang dipilih - final sel = DateTime( - state.selectedDate!.year, - state.selectedDate!.month, - state.selectedDate!.day, - ); - filteredData = state.history - .where((e) { - final d = - DateTime(e.timestamp.year, e.timestamp.month, e.timestamp.day); - return d == sel; - }) - .toList() - .reversed - .toList(); - } else if (state.selectedPeriod == 'Hari Ini') { - // Mode Hari Ini: hanya data hari ini - filteredData = state.history - .where((e) { - final d = - DateTime(e.timestamp.year, e.timestamp.month, e.timestamp.day); - return d == today; - }) - .toList() - .reversed - .toList(); - } else if (state.selectedPeriod == 'Minggu Ini') { - // Mode Minggu Ini: 7 hari ke belakang dari hari ini - final weekStart = today.subtract(const Duration(days: 6)); - filteredData = state.history - .where((e) { - final d = - DateTime(e.timestamp.year, e.timestamp.month, e.timestamp.day); - return !d.isBefore(weekStart) && !d.isAfter(today); - }) - .toList() - .reversed - .toList(); - } else if (state.selectedPeriod == 'Bulan Ini') { - // Mode Bulan Ini: hanya data bulan & tahun yang sama - filteredData = state.history - .where((e) => - e.timestamp.year == now.year && e.timestamp.month == now.month) - .toList() - .reversed - .toList(); - } else { - filteredData = state.history.reversed.toList(); - } - - // โœ… Header label sesuai mode - final String listTitle; - if (state.viewMode == EvaporasiViewMode.customDate && - state.selectedDate != null) { - listTitle = 'Data ${_formatDateInfo(state.selectedDate!)}'; - } else if (state.selectedPeriod == 'Hari Ini') { - listTitle = 'Data Hari Ini'; - } else if (state.selectedPeriod == 'Minggu Ini') { - listTitle = 'Data Minggu Ini'; - } else if (state.selectedPeriod == 'Bulan Ini') { - listTitle = 'Data Bulan Ini'; - } else { - listTitle = 'List Data Evaporasi'; - } - - if (filteredData.isEmpty) { - 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: [ - Text( - listTitle, - style: - const TextStyle(fontSize: 14, fontWeight: FontWeight.bold), - ), - const SizedBox(height: 12), - const Text( - 'Belum ada data untuk periode ini', - style: TextStyle(color: Colors.grey), - ), - ], - ), - ); - } - - 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: [ - Text( - listTitle, - style: const TextStyle(fontSize: 14, fontWeight: FontWeight.bold), - ), - const SizedBox(height: 12), - SizedBox( - height: 280, - child: ListView.separated( - itemCount: filteredData.length, - itemBuilder: (context, index) { - final e = filteredData[index]; - final dateLabel = - '${DateFormat('dd MMM yyyy', 'id_ID').format(e.timestamp)} โ€ข ${DateFormat('HH:mm:ss', 'id_ID').format(e.timestamp)}'; - - return Padding( - padding: const EdgeInsets.symmetric(vertical: 10), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Expanded( - child: Text( - dateLabel, - style: const TextStyle( - fontSize: 12, - color: Colors.black87, - ), - overflow: TextOverflow.ellipsis, - ), - ), - const SizedBox(width: 12), - Column( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Text( - '${e.evaporasi.toStringAsFixed(1)} mm', - style: const TextStyle( - fontSize: 13, - fontWeight: FontWeight.w700, - color: Colors.blue, - ), - ), - const SizedBox(height: 4), - Text( - 'Tinggi Air: ${e.tinggiAir.toStringAsFixed(1)} cm', - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.w600, - color: Colors.blue.shade700, - ), - ), - const SizedBox(height: 4), - Text( - 'Suhu: ${e.suhu.toStringAsFixed(1)} ยฐC', - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.w600, - color: Colors.orange.shade700, - ), - ), - const SizedBox(height: 4), - Text( - _statusTextForHistory(e.evaporasi), - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.w700, - color: _statusColorForHistory(e.evaporasi), - ), - ), - ], - ), - ], - ), - ); - }, - separatorBuilder: (_, __) => const Divider(height: 1), - ), - ), - ], - ), - ); - } - - String _statusTextForHistory(double evaporasi) { - if (evaporasi <= 5.0) return 'Status: Normal'; - if (evaporasi <= 10.0) return 'Status: Sedang'; - return 'Status: Tinggi'; - } - - Color _statusColorForHistory(double evaporasi) { - if (evaporasi <= 5.0) return Colors.green; - if (evaporasi <= 10.0) return Colors.orange; - return Colors.red; - } - - String _formatDateInfo(DateTime date) { - final now = DateTime.now(); - final today = DateTime(now.year, now.month, now.day); - final yesterday = today.subtract(const Duration(days: 1)); - final selected = DateTime(date.year, date.month, date.day); - - if (selected == today) { - return "Hari Ini"; - } else if (selected == yesterday) { - return "Kemarin"; - } else { - return DateFormat('EEEE, d MMMM yyyy', 'id_ID').format(date); - } - } -} - +} \ No newline at end of file diff --git a/lib/screens/monitoring/evaporasi/views/widgets/evaporasi_chart_widget.dart b/lib/screens/monitoring/evaporasi/views/widgets/evaporasi_chart_widget.dart index 58239a9..722514f 100644 --- a/lib/screens/monitoring/evaporasi/views/widgets/evaporasi_chart_widget.dart +++ b/lib/screens/monitoring/evaporasi/views/widgets/evaporasi_chart_widget.dart @@ -311,21 +311,30 @@ class EvaporasiChartWidget extends StatelessWidget { ); return Container( - height: 400, - padding: const EdgeInsets.fromLTRB(8, 12, 8, 4), + width: double.infinity, + padding: const EdgeInsets.all(16), decoration: BoxDecoration( color: Colors.white, - borderRadius: BorderRadius.circular(25), - boxShadow: [ - BoxShadow( - color: Colors.black.withAlpha(13), - blurRadius: 10, - offset: const Offset(0, 5), - ) - ], + borderRadius: BorderRadius.circular(20), ), child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ + const Text( + '', + // NOTE: placeholder; judul seragam dengan AtmosphericScreen. + // Jika ingin judul Evaporasi, ganti sesuai kebutuhan. + style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold), + ), + const SizedBox(height: 12), + SizedBox( + height: 220, + child: ClipRRect( + borderRadius: BorderRadius.circular(16), + child: chart, + ), + ), + const SizedBox(height: 12), Row( mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ @@ -334,19 +343,15 @@ class EvaporasiChartWidget extends StatelessWidget { Container( width: 10, height: 10, - decoration: BoxDecoration( - color: Colors.blue.shade700, + decoration: const BoxDecoration( + color: Colors.blue, shape: BoxShape.circle, ), ), const SizedBox(width: 6), const Text( 'Evaporasi (mm)', - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.w600, - color: Colors.blueGrey, - ), + style: TextStyle(fontSize: 12, fontWeight: FontWeight.w600), ), ], ), @@ -355,31 +360,20 @@ class EvaporasiChartWidget extends StatelessWidget { Container( width: 10, height: 10, - decoration: BoxDecoration( - color: Colors.orange.shade700, + decoration: const BoxDecoration( + color: Colors.orange, shape: BoxShape.circle, ), ), const SizedBox(width: 6), const Text( 'Suhu (ยฐC)', - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.w600, - color: Colors.brown, - ), + style: TextStyle(fontSize: 12, fontWeight: FontWeight.w600), ), ], ), ], ), - const SizedBox(height: 8), - Expanded( - child: ClipRRect( - borderRadius: BorderRadius.circular(16), - child: chart, - ), - ), ], ), ); diff --git a/lib/screens/monitoring/evaporasi/views/widgets/evaporasi_date_picker.dart b/lib/screens/monitoring/evaporasi/views/widgets/evaporasi_date_picker.dart deleted file mode 100644 index ebf6d22..0000000 --- a/lib/screens/monitoring/evaporasi/views/widgets/evaporasi_date_picker.dart +++ /dev/null @@ -1,237 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:table_calendar/table_calendar.dart'; - -import 'package:intl/intl.dart'; -import '../../blocs/evaporasi_bloc.dart'; - -/// ๐Ÿ“… EVAPORASI DATE PICKER - WhatsApp Style -class EvaporasiDatePicker extends StatefulWidget { - const EvaporasiDatePicker({super.key}); - - @override - State createState() => _EvaporasiDatePickerState(); -} - -class _EvaporasiDatePickerState extends State { - CalendarFormat _calendarFormat = CalendarFormat.month; - DateTime _focusedDay = DateTime.now(); - DateTime? _selectedDay; - - @override - void initState() { - super.initState(); - _selectedDay = _focusedDay; - } - - @override - Widget build(BuildContext context) { - return Container( - height: 400, - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(20), - ), - child: Column( - children: [ - // Header with close button - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - TextButton( - onPressed: () { - // Kembali ke mode period DAN TUTUP bottom sheet - context.read().add( - const EvaporasiViewModeChanged( - EvaporasiViewMode.period), - ); - Navigator.of(context).pop(); - }, - child: const Text( - "Kembali", - style: TextStyle(color: Colors.grey), - ), - ), - Text( - "Pilih Tanggal", - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.bold, - color: Colors.grey.shade700, - ), - ), - TextButton( - onPressed: () { - if (_selectedDay != null) { - context.read().add( - EvaporasiDateSelected(_selectedDay!), - ); - Navigator.of(context).pop(); - } - }, - child: const Text( - "OK", - style: TextStyle( - color: Colors.blue, - fontWeight: FontWeight.bold, - ), - ), - ), - ], - ), - ), - // Calendar - Expanded( - child: SingleChildScrollView( - child: TableCalendar( - firstDay: DateTime.utc(2020, 1, 1), - lastDay: DateTime.now(), - focusedDay: _focusedDay, - calendarFormat: _calendarFormat, - selectedDayPredicate: (day) { - return isSameDay(_selectedDay, day); - }, - onDaySelected: (selectedDay, focusedDay) { - setState(() { - _selectedDay = selectedDay; - _focusedDay = focusedDay; - }); - }, - onFormatChanged: (format) { - if (_calendarFormat != format) { - setState(() { - _calendarFormat = format; - }); - } - }, - onPageChanged: (focusedDay) { - setState(() { - _focusedDay = focusedDay; - }); - }, - calendarStyle: CalendarStyle( - // Default - defaultDecoration: const BoxDecoration( - color: Colors.transparent, - shape: BoxShape.circle, - ), - // Today - todayDecoration: BoxDecoration( - color: Colors.blue.shade100, - shape: BoxShape.circle, - ), - todayTextStyle: TextStyle( - color: Colors.blue.shade700, - fontWeight: FontWeight.bold, - ), - // Selected - selectedDecoration: const BoxDecoration( - color: Colors.blue, - shape: BoxShape.circle, - ), - selectedTextStyle: const TextStyle( - color: Colors.white, - fontWeight: FontWeight.bold, - ), - // Outside days - outsideDaysVisible: false, - weekendTextStyle: TextStyle(color: Colors.grey.shade600), - ), - headerStyle: HeaderStyle( - formatButtonVisible: true, - titleCentered: true, - formatButtonShowsNext: false, - formatButtonDecoration: BoxDecoration( - border: Border.all(color: Colors.blue), - borderRadius: BorderRadius.circular(12), - ), - formatButtonTextStyle: const TextStyle( - color: Colors.blue, - ), - titleTextStyle: TextStyle( - fontSize: 16, - fontWeight: FontWeight.bold, - color: Colors.grey.shade700, - ), - leftChevronIcon: Icon( - Icons.chevron_left, - color: Colors.grey.shade600, - ), - rightChevronIcon: Icon( - Icons.chevron_right, - color: Colors.grey.shade600, - ), - ), - daysOfWeekStyle: DaysOfWeekStyle( - weekdayStyle: TextStyle( - color: Colors.grey.shade500, - fontWeight: FontWeight.w500, - fontSize: 12, - ), - weekendStyle: TextStyle( - color: Colors.grey.shade500, - fontWeight: FontWeight.w500, - fontSize: 12, - ), - ), - ), - ), - ), - // Selected date display - if (_selectedDay != null) - Container( - width: double.infinity, - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: Colors.grey.shade100, - borderRadius: const BorderRadius.only( - bottomLeft: Radius.circular(20), - bottomRight: Radius.circular(20), - ), - ), - child: Text( - _formatDate(_selectedDay!), - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w500, - color: Colors.grey.shade700, - ), - ), - ), - ], - ), - ); - } - - String _formatDate(DateTime date) { - final now = DateTime.now(); - final today = DateTime(now.year, now.month, now.day); - final yesterday = today.subtract(const Duration(days: 1)); - final selected = DateTime(date.year, date.month, date.day); - - if (selected == today) { - return "Hari Ini"; - } else if (selected == yesterday) { - return "Kemarin"; - } else { - return DateFormat('EEEE, d MMMM yyyy', 'id_ID').format(date); - } - } -} - -/// ๐Ÿ”น Show Date Picker Dialog -void showEvaporasiDatePicker(BuildContext context) { - showModalBottomSheet( - context: context, - isScrollControlled: true, - backgroundColor: Colors.transparent, - builder: (context) => Container( - margin: const EdgeInsets.all(16), - height: 450, - child: const EvaporasiDatePicker(), - ), - ); -} diff --git a/lib/screens/monitoring/evaporasi/views/widgets/evaporasi_history_list.dart b/lib/screens/monitoring/evaporasi/views/widgets/evaporasi_history_list.dart new file mode 100644 index 0000000..ed10a76 --- /dev/null +++ b/lib/screens/monitoring/evaporasi/views/widgets/evaporasi_history_list.dart @@ -0,0 +1,476 @@ +// =========================================================== +// evaporasi_history_list.dart +// Lokasi: lib/screens/monitoring/evaporasi/views/widgets/ +// =========================================================== + +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; +import 'package:monitoring_repository/monitoring_repository.dart'; + +class EvaporasiHistoryList extends StatelessWidget { + final List history; + final DateTime? selectedDate; + final VoidCallback onPickDate; + final VoidCallback onClearDate; + + const EvaporasiHistoryList({ + super.key, + required this.history, + required this.selectedDate, + required this.onPickDate, + required this.onClearDate, + }); + + Map> _groupByDate(List list) { + final map = >{}; + for (final item in list) { + final key = DateFormat('yyyy-MM-dd').format(item.timestamp); + map.putIfAbsent(key, () => []).add(item); + } + return map; + } + + @override + Widget build(BuildContext context) { + final grouped = _groupByDate(history); + final sortedKeys = grouped.keys.toList() + ..sort((a, b) => b.compareTo(a)); // terbaru di atas + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _HeaderBar( + selectedDate: selectedDate, + totalCount: history.length, + onPickDate: onPickDate, + onClearDate: onClearDate, + ), + const SizedBox(height: 12), + if (history.isEmpty) + _EmptyState(hasFilter: selectedDate != null) + else + ListView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: sortedKeys.length, + itemBuilder: (context, idx) { + final dateKey = sortedKeys[idx]; + final items = grouped[dateKey]! + ..sort((a, b) => b.timestamp.compareTo(a.timestamp)); + final label = _formatDateLabel(dateKey); + return _DateGroup(label: label, items: items); + }, + ), + ], + ); + } + + String _formatDateLabel(String key) { + final dt = DateTime.parse(key); + final today = DateTime.now(); + if (dt.year == today.year && + dt.month == today.month && + dt.day == today.day) { + return 'Hari Ini โ€” ${DateFormat('dd MMMM yyyy', 'id_ID').format(dt)}'; + } + final yesterday = today.subtract(const Duration(days: 1)); + if (dt.year == yesterday.year && + dt.month == yesterday.month && + dt.day == yesterday.day) { + return 'Kemarin โ€” ${DateFormat('dd MMMM yyyy', 'id_ID').format(dt)}'; + } + return DateFormat('EEEE, dd MMMM yyyy', 'id_ID').format(dt); + } +} + +// โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• +// Header bar +// โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• +class _HeaderBar extends StatelessWidget { + final DateTime? selectedDate; + final int totalCount; + final VoidCallback onPickDate; + final VoidCallback onClearDate; + + const _HeaderBar({ + required this.selectedDate, + required this.totalCount, + required this.onPickDate, + required this.onClearDate, + }); + + @override + Widget build(BuildContext context) { + final filtered = selectedDate != null; + return Row( + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Riwayat Data', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: Colors.black87), + ), + Text( + filtered + ? '${DateFormat('dd MMM yyyy', 'id_ID').format(selectedDate!)} โ€ข $totalCount data' + : '$totalCount data tersimpan', + style: TextStyle(fontSize: 12, color: Colors.grey.shade600), + ), + ], + ), + const Spacer(), + if (filtered) + _ChipButton( + label: DateFormat('dd MMM yyyy', 'id_ID').format(selectedDate!), + icon: Icons.close_rounded, + color: Colors.blue.shade700, + onTap: onClearDate, + ) + else + _ChipButton( + label: 'Filter Tanggal', + icon: Icons.calendar_month_rounded, + color: Colors.blue.shade700, + onTap: onPickDate, + ), + ], + ); + } +} + +class _ChipButton extends StatelessWidget { + final String label; + final IconData icon; + final Color color; + final VoidCallback onTap; + + const _ChipButton({ + required this.label, + required this.icon, + required this.color, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + return InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(20), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(20), + border: Border.all(color: color.withValues(alpha: 0.4)), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 14, color: color), + const SizedBox(width: 6), + Text(label, + style: TextStyle( + fontSize: 12, + color: color, + fontWeight: FontWeight.w600)), + ], + ), + ), + ); + } +} + +// โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• +// Group per tanggal +// โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• +class _DateGroup extends StatefulWidget { + final String label; + final List items; + + const _DateGroup({required this.label, required this.items}); + + @override + State<_DateGroup> createState() => _DateGroupState(); +} + +class _DateGroupState extends State<_DateGroup> { + bool _expanded = true; + + double get _avgEvap { + if (widget.items.isEmpty) return 0; + return widget.items.map((e) => e.evaporasi).reduce((a, b) => a + b) / + widget.items.length; + } + + double get _maxEvap { + if (widget.items.isEmpty) return 0; + return widget.items + .map((e) => e.evaporasi) + .reduce((a, b) => a > b ? a : b); + } + + @override + Widget build(BuildContext context) { + return Container( + margin: const EdgeInsets.only(bottom: 12), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.05), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + child: Column( + children: [ + // โ”€โ”€ Group header โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + InkWell( + onTap: () => setState(() => _expanded = !_expanded), + borderRadius: + const BorderRadius.vertical(top: Radius.circular(16)), + child: Padding( + padding: + const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Row( + children: [ + Container( + width: 4, + height: 36, + decoration: BoxDecoration( + color: Colors.blue.shade600, + borderRadius: BorderRadius.circular(2), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(widget.label, + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 13)), + const SizedBox(height: 2), + Text( + '${widget.items.length} data โ€ข rata-rata ${_avgEvap.toStringAsFixed(2)} mm โ€ข maks ${_maxEvap.toStringAsFixed(2)} mm', + style: TextStyle( + fontSize: 11, color: Colors.grey.shade600), + ), + ], + ), + ), + Icon( + _expanded + ? Icons.keyboard_arrow_up_rounded + : Icons.keyboard_arrow_down_rounded, + color: Colors.grey.shade500, + ), + ], + ), + ), + ), + + // โ”€โ”€ Item list โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + if (_expanded) + ListView.separated( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: widget.items.length, + separatorBuilder: (_, __) => + Divider(height: 1, color: Colors.grey.shade100), + itemBuilder: (context, i) => + _HistoryItemTile(item: widget.items[i]), + ), + ], + ), + ); + } +} + +// โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• +// Satu baris data history +// โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• +class _HistoryItemTile extends StatelessWidget { + final Evaporasi item; + + const _HistoryItemTile({required this.item}); + + String get _status { + if (item.evaporasi > 10.0) return 'Tinggi'; + if (item.evaporasi >= 2.0) return 'Normal'; + return 'Rendah'; + } + + Color get _statusColor { + switch (_status) { + case 'Tinggi': + return Colors.red.shade600; + case 'Normal': + return Colors.orange.shade700; + default: + return Colors.green.shade600; + } + } + + IconData get _statusIcon { + switch (_status) { + case 'Tinggi': + return Icons.warning_rounded; + case 'Normal': + return Icons.info_outline_rounded; + default: + return Icons.check_circle_outline_rounded; + } + } + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // โ”€โ”€ Jam โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + SizedBox( + width: 56, + child: Text( + DateFormat('HH:mm:ss').format(item.timestamp), + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + fontFamily: 'monospace'), + ), + ), + const SizedBox(width: 8), + + // โ”€โ”€ Data evaporasi, tinggi air, suhu โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Evaporasi + RichText( + text: TextSpan( + style: const TextStyle(color: Colors.black87), + children: [ + TextSpan( + text: item.evaporasi.toStringAsFixed(2), + style: const TextStyle( + fontSize: 15, fontWeight: FontWeight.bold), + ), + const TextSpan( + text: ' mm', + style: + TextStyle(fontSize: 11, color: Colors.black54), + ), + ], + ), + ), + const SizedBox(height: 3), + // Tinggi Air + Row( + children: [ + Icon(Icons.water, size: 11, + color: Colors.blue.shade400), + const SizedBox(width: 3), + Text( + 'Tinggi Air: ${item.tinggiAir.toStringAsFixed(1)} cm', + style: TextStyle( + fontSize: 11, color: Colors.blue.shade600), + ), + ], + ), + const SizedBox(height: 2), + // Suhu + Row( + children: [ + Icon(Icons.thermostat, size: 11, + color: Colors.orange.shade400), + const SizedBox(width: 3), + Text( + 'Suhu: ${item.suhu.toStringAsFixed(1)} ยฐC', + style: TextStyle( + fontSize: 11, color: Colors.orange.shade700), + ), + ], + ), + ], + ), + ), + + // โ”€โ”€ Badge status โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + Container( + padding: + const EdgeInsets.symmetric(horizontal: 10, vertical: 5), + decoration: BoxDecoration( + color: _statusColor.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(20), + border: + Border.all(color: _statusColor.withValues(alpha: 0.4)), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(_statusIcon, size: 12, color: _statusColor), + const SizedBox(width: 4), + Text( + _status, + style: TextStyle( + fontSize: 11, + color: _statusColor, + fontWeight: FontWeight.w600), + ), + ], + ), + ), + ], + ), + ); + } +} + +// โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• +// Empty state +// โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• +class _EmptyState extends StatelessWidget { + final bool hasFilter; + const _EmptyState({required this.hasFilter}); + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(vertical: 40), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + ), + child: Column( + children: [ + Icon( + hasFilter + ? Icons.search_off_rounded + : Icons.inbox_rounded, + size: 48, + color: Colors.grey.shade300, + ), + const SizedBox(height: 12), + Text( + hasFilter + ? 'Tidak ada data untuk tanggal ini' + : 'Belum ada data history', + style: + TextStyle(color: Colors.grey.shade500, fontSize: 14), + ), + ], + ), + ); + } +} \ No newline at end of file diff --git a/lib/screens/monitoring/evaporasi/views/widgets/evaporasi_range_selector.dart b/lib/screens/monitoring/evaporasi/views/widgets/evaporasi_range_selector.dart new file mode 100644 index 0000000..095e934 --- /dev/null +++ b/lib/screens/monitoring/evaporasi/views/widgets/evaporasi_range_selector.dart @@ -0,0 +1,206 @@ +// lib/screens/monitoring/evaporasi/views/widgets/evaporasi_range_selector.dart + +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:intl/intl.dart'; +import '../../blocs/evaporasi_bloc.dart'; + +class EvaporasiRangeSelector extends StatelessWidget { + const EvaporasiRangeSelector({super.key}); + + @override + Widget build(BuildContext context) { + final state = context.watch().state; + final start = state.startDate; + final end = state.endDate; + final isSingle = state.isSingleDay; + + final label = isSingle + ? _formatSingle(start) + : '${_fmt(start)} โ†’ ${_fmt(end)}'; + + return GestureDetector( + onTap: () => _pickRange(context, start, end), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.06), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + // โ”€โ”€ Label rentang โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + isSingle ? 'Tampil per Jam' : 'Tampil per Hari', + style: TextStyle( + fontSize: 11, + color: Colors.grey.shade500, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 2), + Text( + label, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.bold, + color: Colors.black87, + ), + ), + ], + ), + + // โ”€โ”€ Tombol pilih โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + Row( + children: [ + // Shortcut: Hari Ini + _ShortcutChip( + label: 'Hari Ini', + isActive: isSingle && _isToday(start), + onTap: () { + final today = DateTime.now(); + context.read().add( + EvaporasiDateRangeChanged( + startDate: DateTime(today.year, today.month, today.day), + endDate: DateTime(today.year, today.month, today.day), + ), + ); + }, + ), + const SizedBox(width: 6), + // Tombol pilih range bebas + GestureDetector( + onTap: () => _pickRange(context, start, end), + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: Colors.blue.shade600, + borderRadius: BorderRadius.circular(20), + ), + child: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.date_range_rounded, + size: 14, color: Colors.white), + SizedBox(width: 5), + Text( + 'Pilih Tanggal', + style: TextStyle( + fontSize: 12, + color: Colors.white, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + ), + ], + ), + ], + ), + ), + ); + } + + Future _pickRange( + BuildContext context, DateTime start, DateTime end) async { + final picked = await showDateRangePicker( + context: context, + firstDate: DateTime(2024), + lastDate: DateTime.now(), + initialDateRange: DateTimeRange(start: start, end: end), + locale: const Locale('id', 'ID'), + builder: (context, child) { + return Theme( + data: Theme.of(context).copyWith( + colorScheme: ColorScheme.light( + primary: Colors.blue.shade700, + onPrimary: Colors.white, + surface: Colors.white, + ), + ), + child: child!, + ); + }, + ); + + if (picked != null && context.mounted) { + context.read().add( + EvaporasiDateRangeChanged( + startDate: picked.start, + endDate: picked.end, + ), + ); + } + } + + bool _isToday(DateTime d) { + final now = DateTime.now(); + return d.year == now.year && d.month == now.month && d.day == now.day; + } + + String _fmt(DateTime d) => DateFormat('dd MMM yyyy', 'id_ID').format(d); + + String _formatSingle(DateTime d) { + if (_isToday(d)) return 'Hari Ini โ€” ${_fmt(d)}'; + final yesterday = DateTime.now().subtract(const Duration(days: 1)); + if (d.year == yesterday.year && + d.month == yesterday.month && + d.day == yesterday.day) { + return 'Kemarin โ€” ${_fmt(d)}'; + } + return DateFormat('EEEE, dd MMMM yyyy', 'id_ID').format(d); + } +} + +class _ShortcutChip extends StatelessWidget { + final String label; + final bool isActive; + final VoidCallback onTap; + + const _ShortcutChip({ + required this.label, + required this.isActive, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + decoration: BoxDecoration( + color: isActive ? Colors.blue.shade50 : Colors.grey.shade100, + borderRadius: BorderRadius.circular(20), + border: Border.all( + color: isActive + ? Colors.blue.shade400 + : Colors.grey.shade300, + ), + ), + child: Text( + label, + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: isActive ? Colors.blue.shade700 : Colors.grey.shade600, + ), + ), + ), + ); + } +} \ No newline at end of file diff --git a/output_flutter_run.txt b/output_flutter_run.txt new file mode 100644 index 0000000000000000000000000000000000000000..58a58cc165598063814c79cf46a6bb8efe50e086 GIT binary patch literal 370602 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 literal 0 HcmV?d00001 diff --git a/packages/monitoring_repository/lib/src/models/evaporasi.dart b/packages/monitoring_repository/lib/src/models/evaporasi.dart index 76af119..c3007e3 100644 --- a/packages/monitoring_repository/lib/src/models/evaporasi.dart +++ b/packages/monitoring_repository/lib/src/models/evaporasi.dart @@ -1,3 +1,5 @@ +// packages/monitoring_repository/lib/src/models/evaporasi.dart + class Evaporasi { final double evaporasi; final double suhu; @@ -33,7 +35,7 @@ class Evaporasi { return 0.0; } - // Evaporasi (mm) + // โ”€โ”€ Evaporasi (mm) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ final evaporasiVal = toDoubleSafe( json['evaporasi_mm'] ?? json['evaporasi'] ?? @@ -44,9 +46,11 @@ class Evaporasi { json['evaporasi_k'], ); - // Suhu (ยฐC) + // โ”€โ”€ Suhu (ยฐC) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + // FIX: tambah 'suhu_air_c' sesuai field yang dikirim ESP32 final suhuRaw = toDoubleSafe( - json['suhu_air'] ?? + json['suhu_air_c'] ?? // โ† ESP32 kirim field ini + json['suhu_air'] ?? json['suhu'] ?? json['suhuAir'] ?? json['temp'] ?? @@ -54,7 +58,7 @@ class Evaporasi { ); final suhuVal = (suhuRaw < -50 || suhuRaw > 100) ? 0.0 : suhuRaw; - // Tinggi air + // โ”€โ”€ Tinggi Air (cm) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ final tinggiVal = toDoubleSafe( json['tinggi_air_cm'] ?? json['tinggi_air'] ?? @@ -66,16 +70,18 @@ class Evaporasi { json['tinggiAir_m'], ); - // Filter data invalid - final evaporasiFiltered = (evaporasiVal < 0 || evaporasiVal > 50) - ? 0.0 - : evaporasiVal; - final tinggiFiltered = (tinggiVal < 0 || tinggiVal > 100) ? 0.0 : tinggiVal; + // โ”€โ”€ Sanity check โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + final evaporasiFiltered = + (evaporasiVal < 0 || evaporasiVal > 50) ? 0.0 : evaporasiVal; + final tinggiFiltered = + (tinggiVal < 0 || tinggiVal > 100) ? 0.0 : tinggiVal; + // โ”€โ”€ Parse Timestamp โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + // FIX: Tambahkan offset +07:00 (WIB) jika string tidak punya info timezone, + // agar tidak terjadi mismatch 7 jam antara Firebase dan Flutter. DateTime parseTimestamp(dynamic rawTimestamp) { try { if (rawTimestamp is int) { - // If seconds, convert to ms. if (rawTimestamp < 1000000000000) { return DateTime.fromMillisecondsSinceEpoch(rawTimestamp * 1000) .toLocal(); @@ -86,8 +92,7 @@ class Evaporasi { if (rawTimestamp is double) { final value = rawTimestamp.toInt(); if (value < 1000000000000) { - return DateTime.fromMillisecondsSinceEpoch(value * 1000) - .toLocal(); + return DateTime.fromMillisecondsSinceEpoch(value * 1000).toLocal(); } return DateTime.fromMillisecondsSinceEpoch(value).toLocal(); } @@ -105,11 +110,17 @@ class Evaporasi { return DateTime.fromMillisecondsSinceEpoch(unixValue).toLocal(); } - // Firebase sometimes uses "YYYY-MM-DD HH:mm:ss" (needs ISO 'T') + // Format "YYYY-MM-DD HH:mm:ss" โ†’ tambah 'T' agar bisa diparsing if (s.contains(' ') && !s.contains('T')) { s = s.replaceFirst(' ', 'T'); } + // FIX: Jika tidak ada info timezone, anggap WIB (UTC+7) + // agar jam di chart tidak mismatch 7 jam + if (!s.contains('+') && !s.contains('Z') && !s.contains('-', 10)) { + s = '${s}+07:00'; + } + final parsed = DateTime.tryParse(s); if (parsed != null) return parsed.toLocal(); } @@ -126,7 +137,7 @@ class Evaporasi { if (rawTimestamp != null) { timestamp = parseTimestamp(rawTimestamp); } else { - // legacy fallback: "waktu" format "HH:mm:ss" + // Legacy fallback: field "waktu" format "HH:mm:ss" final waktuStr = json['waktu'] as String?; if (waktuStr != null) { final parts = waktuStr.split(':'); @@ -136,7 +147,8 @@ class Evaporasi { final detik = parts.length >= 3 ? (int.tryParse(parts[2]) ?? 0) : 0; final now = DateTime.now(); - timestamp = DateTime(now.year, now.month, now.day, jam, menit, detik); + timestamp = + DateTime(now.year, now.month, now.day, jam, menit, detik); } } } @@ -148,5 +160,4 @@ class Evaporasi { timestamp: timestamp, ); } -} - +} \ No newline at end of file diff --git a/tool_check.txt b/tool_check.txt new file mode 100644 index 0000000000000000000000000000000000000000..25b690689b298649c027af668c051282a96eed6c GIT binary patch literal 14 VcmezWuY@6$p_rkBftP`c0RSrC1JwWk literal 0 HcmV?d00001