update search periode

This commit is contained in:
Mochamad ongki ramadani 2026-05-01 21:00:57 +07:00
parent 08aefa5634
commit 2d7ee2ea98
10 changed files with 512 additions and 25 deletions

37
TODO.md
View File

@ -1,11 +1,30 @@
# TODO: Fitur Baru Evaporasi (Period Selector + Status + Notifikasi)
# TODO - Evaporasi Date Picker Feature
## Steps
- [x] 1. Edit `evaporasi_state.dart` — tambahkan `weatherStatus` & `willRain`
- [x] 2. Edit `evaporasi_bloc.dart` — hitung status & update global notifier
- [x] 3. Create `notification_notifier.dart` — global ValueNotifier untuk alert
- [x] 4. Create `evaporasi_period_selector.dart` — tombol periode
- [x] 5. Edit `evaporasi_screen.dart` — tambahkan period selector & status card
- [x] 6. Edit `home_screen.dart` — badge merah di icon lonceng
- [x] 7. Verifikasi kompilasi
## Status: COMPLETED ✅
### Steps:
- [x] 1. Add table_calendar package to pubspec.yaml
- [x] 2. Update EvaporasiState - add selectedDate and EvaporasiViewMode
- [x] 3. Update EvaporasiEvent - add EvaporasiDateSelected and EvaporasiViewModeChanged
- [x] 4. Update EvaporasiBloc - handle date selection + view mode
- [x] 5. Add toSpecificDate function to TimeSeriesMapper
- [x] 6. Create EvaporasiDatePicker widget (WhatsApp-style calendar)
- [x] 7. Update EvaporasiPeriodSelector - add date picker button
- [x] 8. Update EvaporasiScreen - integrate date picker + show selected date
- [x] 9. Update EvaporasiChartWidget - handle custom date display (already supports "Tanggal Khusus")
### Summary:
Feature implemented successfully:
1. **Date Picker Button**: Added next to period tabs (Hari Ini, Minggu Ini, Bulan Ini)
2. **WhatsApp-style Calendar**: Using table_calendar with Indonesian format
3. **Custom Date Display**: Shows selected date above the period selector
4. **24-hour Chart**: For custom date, shows hourly data (same as "Hari Ini")
5. **Period Mode**: Users can switch back to period mode by clicking any period tab
### Notes:
- Using table_calendar: ^3.1.2
- Date format: Indonesian locale (id_ID)
- Similar to WhatsApp chat date picker functionality

View File

@ -91,6 +91,34 @@ class TimeSeriesMapper {
});
}
/// =========================
/// 📅 SPECIFIC DATE (24 JAM - TANGGAL KHUSUS)
/// =========================
static List<double> toSpecificDate<T>({
required List<T> data,
required DateTime Function(T) getTime,
required double Function(T) getValue,
required DateTime targetDate,
}) {
final sums = List<double>.filled(24, 0.0);
final counts = List<int>.filled(24, 0);
for (final item in data) {
final time = getTime(item);
if (_isSameDay(time, targetDate)) {
final hour = time.hour;
sums[hour] += getValue(item);
counts[hour]++;
}
}
return List.generate(24, (i) {
if (counts[i] == 0) return 0;
return sums[i] / counts[i];
});
}
/// =========================
/// 🧠 HELPER
/// =========================

View File

@ -23,6 +23,8 @@ class EvaporasiBloc extends Bloc<EvaporasiEvent, EvaporasiState> {
on<WatchEvaporasiStarted>(_onStarted);
on<_EvaporasiRealtimeUpdated>(_onRealtimeUpdated);
on<EvaporasiPeriodChanged>(_onPeriodChanged);
on<EvaporasiDateSelected>(_onDateSelected);
on<EvaporasiViewModeChanged>(_onViewModeChanged);
}
/// =========================
@ -149,7 +151,7 @@ class EvaporasiBloc extends Bloc<EvaporasiEvent, EvaporasiState> {
);
}
// Pertahankan status cuaca saat ini (tidak berubah karena hanya ganti periode)
// Pertahankan status cuaca saat ini (tidak berubah karena hanya ganti periode)
emit(state.copyWith(
dailyValues: updated,
dailyTemperatures: updatedTemp,
@ -157,6 +159,57 @@ class EvaporasiBloc extends Bloc<EvaporasiEvent, EvaporasiState> {
));
}
/// =========================
/// 📅 DATE SELECTED (Custom Date)
/// =========================
Future<void> _onDateSelected(
EvaporasiDateSelected event,
Emitter<EvaporasiState> 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,
);
emit(state.copyWith(
dailyValues: updated,
dailyTemperatures: updatedTemp,
isLoading: false,
));
}
/// =========================
/// 🔄 VIEW MODE CHANGED
/// =========================
Future<void> _onViewModeChanged(
EvaporasiViewModeChanged event,
Emitter<EvaporasiState> emit,
) async {
if (event.mode == EvaporasiViewMode.period) {
// Kembali ke mode period - trigger period change
add(EvaporasiPeriodChanged(state.selectedPeriod));
} else {
emit(state.copyWith(viewMode: event.mode));
}
}
@override
Future<void> close() async {
await _subscription?.cancel();

View File

@ -19,3 +19,23 @@ class EvaporasiPeriodChanged extends EvaporasiEvent {
@override
List<Object> get props => [period];
}
/// 📅 PILIH TANGGAL KHUSUS (Custom Date Picker)
class EvaporasiDateSelected extends EvaporasiEvent {
final DateTime date;
const EvaporasiDateSelected(this.date);
@override
List<Object> get props => [date];
}
/// 🔄 KEMBALI KE MODE PERIOD
class EvaporasiViewModeChanged extends EvaporasiEvent {
final EvaporasiViewMode mode;
const EvaporasiViewModeChanged(this.mode);
@override
List<Object> get props => [mode];
}

View File

@ -1,10 +1,14 @@
part of 'evaporasi_bloc.dart';
enum EvaporasiViewMode { period, customDate }
class EvaporasiState extends Equatable {
final double currentValue; // nilai evaporasi realtime
final double temperature; // suhu (opsional dari firebase)
final double waterLevel; // tinggi air
final String selectedPeriod;
final DateTime? selectedDate; // tanggal spesifik untuk custom date
final EvaporasiViewMode viewMode; // period vs custom date
final List<double> dailyValues; // untuk grafik evaporasi
final List<double> dailyTemperatures; // untuk grafik suhu
@ -20,6 +24,8 @@ class EvaporasiState extends Equatable {
this.temperature = 0.0,
this.waterLevel = 0.0,
this.selectedPeriod = "Hari Ini",
this.selectedDate,
this.viewMode = EvaporasiViewMode.period,
this.dailyValues = const [],
this.dailyTemperatures = const [],
this.history = const [],
@ -28,11 +34,13 @@ class EvaporasiState extends Equatable {
this.isLoading = true,
});
EvaporasiState copyWith({
EvaporasiState copyWith({
double? currentValue,
double? temperature,
double? waterLevel,
String? selectedPeriod,
DateTime? selectedDate,
EvaporasiViewMode? viewMode,
List<double>? dailyValues,
List<double>? dailyTemperatures,
List<Evaporasi>? history,
@ -45,6 +53,8 @@ class EvaporasiState extends Equatable {
temperature: temperature ?? this.temperature,
waterLevel: waterLevel ?? this.waterLevel,
selectedPeriod: selectedPeriod ?? this.selectedPeriod,
selectedDate: selectedDate ?? this.selectedDate,
viewMode: viewMode ?? this.viewMode,
dailyValues: dailyValues ?? this.dailyValues,
dailyTemperatures: dailyTemperatures ?? this.dailyTemperatures,
history: history ?? this.history,
@ -55,11 +65,13 @@ class EvaporasiState extends Equatable {
}
@override
List<Object> get props => [
List<Object?> get props => [
currentValue,
temperature,
waterLevel,
selectedPeriod,
selectedDate,
viewMode,
dailyValues,
dailyTemperatures,
history,

View File

@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:intl/intl.dart';
import '../blocs/evaporasi_bloc.dart';
import 'widgets/evaporasi_chart_widget.dart';
import 'widgets/evaporasi_period_selector.dart';
@ -52,12 +53,44 @@ class EvaporasiScreen extends StatelessWidget {
style:
TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
const SizedBox(height: 15),
const EvaporasiPeriodSelector(),
// Period selector with date picker
Builder(
builder: (context) {
final state = context.watch<EvaporasiBloc>().state;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Show selected date info when in custom mode
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),
EvaporasiChartWidget(
dailyValues: state.dailyValues,
dailyTemperatures: state.dailyTemperatures,
period: state.selectedPeriod,
Builder(
builder: (context) {
final state = context.watch<EvaporasiBloc>().state;
return EvaporasiChartWidget(
dailyValues: state.dailyValues,
dailyTemperatures: state.dailyTemperatures,
period: state.viewMode == EvaporasiViewMode.customDate
? "Tanggal Khusus"
: state.selectedPeriod,
);
},
),
const SizedBox(height: 25),
ExportPdfButton(
@ -223,8 +256,26 @@ class EvaporasiScreen extends StatelessWidget {
],
),
),
],
],
),
);
}
/// =========================
/// 📅 FORMAT DATE INFO (for custom date display)
/// =========================
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);
}
}
}

View File

@ -0,0 +1,232 @@
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<EvaporasiDatePicker> createState() => _EvaporasiDatePickerState();
}
class _EvaporasiDatePickerState extends State<EvaporasiDatePicker> {
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<EvaporasiBloc>().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<EvaporasiBloc>().add(
EvaporasiDateSelected(_selectedDay!),
);
Navigator.of(context).pop();
}
},
child: const Text(
"OK",
style: TextStyle(
color: Colors.blue,
fontWeight: FontWeight.bold,
),
),
),
],
),
),
// Calendar
Expanded(
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) {
_focusedDay = focusedDay;
},
calendarStyle: CalendarStyle(
// Default
defaultDecoration: 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: 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(),
),
);
}

View File

@ -1,14 +1,17 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../blocs/evaporasi_bloc.dart';
import 'evaporasi_date_picker.dart';
class EvaporasiPeriodSelector extends StatelessWidget {
const EvaporasiPeriodSelector({super.key});
@override
Widget build(BuildContext context) {
final selectedPeriod =
context.select((EvaporasiBloc bloc) => bloc.state.selectedPeriod);
final state = context.select((EvaporasiBloc bloc) => bloc.state);
final selectedPeriod = state.selectedPeriod;
final viewMode = state.viewMode;
final selectedDate = state.selectedDate;
return Container(
padding: const EdgeInsets.all(4),
@ -19,16 +22,20 @@ class EvaporasiPeriodSelector extends StatelessWidget {
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
_buildTab(context, "Hari Ini", selectedPeriod),
_buildTab(context, "Minggu Ini", selectedPeriod),
_buildTab(context, "Bulan Ini", selectedPeriod),
_buildTab(context, "Hari Ini", selectedPeriod, viewMode),
_buildTab(context, "Minggu Ini", selectedPeriod, viewMode),
_buildTab(context, "Bulan Ini", selectedPeriod, viewMode),
const SizedBox(width: 8),
// Date picker button
_buildDatePickerButton(context, viewMode, selectedDate),
],
),
);
}
Widget _buildTab(BuildContext context, String label, String current) {
bool isActive = label == current;
Widget _buildTab(BuildContext context, String label, String current, EvaporasiViewMode viewMode) {
// If in customDate mode, show period tabs as inactive
bool isActive = viewMode == EvaporasiViewMode.period && label == current;
return GestureDetector(
onTap: () {
@ -51,5 +58,51 @@ class EvaporasiPeriodSelector extends StatelessWidget {
),
);
}
Widget _buildDatePickerButton(BuildContext context, EvaporasiViewMode viewMode, DateTime? selectedDate) {
final isActive = viewMode == EvaporasiViewMode.customDate;
return GestureDetector(
onTap: () {
// Set mode ke customDate SEBELUM membuka date picker
context.read<EvaporasiBloc>().add(
const EvaporasiViewModeChanged(EvaporasiViewMode.customDate),
);
showEvaporasiDatePicker(context);
},
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: isActive ? Colors.blue : Colors.grey.shade200,
borderRadius: BorderRadius.circular(12),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.calendar_today,
size: 14,
color: isActive ? Colors.white : Colors.grey.shade600,
),
const SizedBox(width: 4),
Text(
isActive && selectedDate != null
? _formatDateShort(selectedDate)
: "Pilih Tanggal",
style: TextStyle(
color: isActive ? Colors.white : Colors.grey.shade600,
fontWeight: FontWeight.bold,
fontSize: 12,
),
),
],
),
),
);
}
String _formatDateShort(DateTime date) {
return "${date.day}/${date.month}";
}
}

View File

@ -687,6 +687,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.27.7"
simple_gesture_detector:
dependency: transitive
description:
name: simple_gesture_detector
sha256: ba2cd5af24ff20a0b8d609cec3f40e5b0744d2a71804a2616ae086b9c19d19a3
url: "https://pub.dev"
source: hosted
version: "0.2.1"
sky_engine:
dependency: transitive
description: flutter
@ -732,6 +740,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.4.1"
table_calendar:
dependency: "direct main"
description:
name: table_calendar
sha256: "0c0c6219878b363a2d5f40c7afb159d845f253d061dc3c822aa0d5fe0f721982"
url: "https://pub.dev"
source: hosted
version: "3.2.0"
term_glyph:
dependency: transitive
description:

View File

@ -4,6 +4,7 @@ publish_to: 'none'
version: 1.0.0+1
environment:
sdk: ^3.1.0
dependencies:
bloc: ^8.1.0
bloc_concurrency: ^0.2.5
@ -15,6 +16,7 @@ dependencies:
firebase_core: ^4.3.0
firebase_database: ^12.1.3
fl_chart: ^1.1.1
table_calendar: ^3.1.2
flutter:
sdk: flutter
flutter_bloc: ^8.1.0
@ -33,6 +35,7 @@ dev_dependencies:
flutter_lints: ^3.0.0
flutter_test:
sdk: flutter
flutter:
uses-material-design: true
assets: