Merge branch 'branch_percobaan'

This commit is contained in:
kleponijo 2026-05-12 15:17:50 +07:00
commit c0e7a1f1d6
25 changed files with 2313 additions and 741 deletions

17
TODO.md
View File

@ -1,11 +1,10 @@
# TODO: Fitur Baru Evaporasi (Period Selector + Status + Notifikasi)
## TODO - Evaporasi (Dual Axis + History Firebase)
### Checklist
- [x] Cek: List Evaporasi sudah mengambil `state.history` dari Firebase `getSensorHistory`.
- [x] Cek: Filter custom date pada list menggunakan `state.history` (data terdahulu sudah tersedia).
- [x] Ubah grafik evaporasi menjadi **dual-axis** (melalui mapping skala + label kiri/kanan).
- [ ] Perbaiki mismatch chart & list dengan sumber history Firebase (sinkronisasi agregasi/label/period + timezone).
- [ ] Jalankan `flutter analyze` dan minimal compile aplikasi.
## 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

View File

@ -7,6 +7,9 @@
# The following line activates a set of recommended lints for Flutter apps,
# packages, and plugins designed to encourage good coding practices.
analyzer:
errors:
unused_local_variable: ignore
include: package:flutter_lints/flutter.yaml
linter:

View File

@ -15,11 +15,10 @@ class MyAppView extends StatelessWidget {
debugShowCheckedModeBanner: false,
theme: ThemeData(
colorScheme: ColorScheme.light(
background: Colors.grey.shade100,
onBackground: Colors.black,
surface: Colors.grey.shade100,
onSurface: Colors.black,
primary: Colors.blue,
onPrimary: Colors.white
),
onPrimary: Colors.white),
),
home: BlocBuilder<AuthenticationBloc, AuthenticationState>(
builder: ((context, state) {
@ -29,7 +28,6 @@ class MyAppView extends StatelessWidget {
return WelcomeScreen();
}
}),
)
);
));
}
}

View File

@ -7,20 +7,19 @@ import 'package:user_repository/user_repository.dart';
part 'authentication_event.dart';
part 'authentication_state.dart';
class AuthenticationBloc extends Bloc<AuthenticationEvent, AuthenticationState> {
class AuthenticationBloc
extends Bloc<AuthenticationEvent, AuthenticationState> {
final UserRepository userRepository;
late final StreamSubscription<MyUser?> _userSubscription;
AuthenticationBloc({
required this.userRepository
}) : super(const AuthenticationState.unknown()) {
AuthenticationBloc({required this.userRepository})
: super(const AuthenticationState.unknown()) {
_userSubscription = userRepository.user.listen((user) {
add(AuthenticationUserChanged(user));
});
on<AuthenticationUserChanged>((event, emit) {
if(event.user != MyUser.empty) {
if (event.user != null && event.user != MyUser.empty) {
emit(AuthenticationState.authenticated(event.user!));
} else {
emit(AuthenticationState.unauthenticated());

View File

@ -91,13 +91,46 @@ 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
/// =========================
/// Sinkronisasi tanggal untuk menghindari mismatch akibat timezone (UTC vs local).
/// Kita bandingkan berdasarkan UTC.
static bool _isSameDay(DateTime a, DateTime b) {
return a.year == b.year && a.month == b.month && a.day == b.day;
final au = a.toUtc();
final bu = b.toUtc();
return au.year == bu.year && au.month == bu.month && au.day == bu.day;
}
static List<double> smooth(List<double> data) {
if (data.length < 3) return data;

View File

@ -13,6 +13,7 @@ class SignInBloc extends Bloc<SignInEvent, SignInState> {
emit(SignInProcess());
try {
await _userRepository.signIn(event.email, event.password);
emit(SignInSuccess());
} catch (e) {
emit(SignInFailure());
}
@ -25,7 +26,6 @@ class SignInBloc extends Bloc<SignInEvent, SignInState> {
emit(SignInSuccess());
} catch (e) {
emit(SignInFailure());
emit(SignInInitial());
}
});

View File

@ -21,31 +21,44 @@ class _SignInScreenState extends State<SignInScreen> {
bool obscurePassword = true;
String? _errorMsg;
@override
void dispose() {
emailController.dispose();
passwordController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return BlocListener<SignInBloc, SignInState>(
listener: (context, state) {
if (state is SignInProcess) {
setState(() {
signInRequired = true;
});
setState(() => signInRequired = true);
} else {
setState(() {
signInRequired = false;
});
setState(() => signInRequired = false);
if (state is SignInFailure) {
setState(() {
// PERUBAHAN: Pesan error lebih umum karena Google gagal belum tentu soal password
_errorMsg = 'Sign in failed. Please try again.';
});
setState(() => _errorMsg = 'Email atau password salah. Coba lagi.');
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(_errorMsg!),
backgroundColor: Colors.red,
),
);
} else if (state is SignInSuccess) {
setState(() => _errorMsg = null);
}
}
},
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Form(
key: _formKey,
child: Column(
children: [
const SizedBox(height: 20),
// Email field
SizedBox(
width: MediaQuery.of(context).size.width * 0.9,
child: MyTextField(
@ -54,14 +67,22 @@ class _SignInScreenState extends State<SignInScreen> {
obscureText: false,
keyboardType: TextInputType.emailAddress,
prefixIcon: const Icon(CupertinoIcons.mail_solid),
errorMsg: _errorMsg,
// FIX: tidak lagi pass errorMsg ke field individual
validator: (val) {
if (val!.isEmpty) {
return 'Please fill in this field';
if (val == null || val.isEmpty) {
return 'Mohon isi field ini';
}
// FIX: tambah validasi format email
if (!RegExp(r'^[\w.-]+@[\w.-]+\.\w{2,}$').hasMatch(val)) {
return 'Format email tidak valid';
}
return null;
})),
},
),
),
const SizedBox(height: 10),
// Password field
SizedBox(
width: MediaQuery.of(context).size.width * 0.9,
child: MyTextField(
@ -70,10 +91,9 @@ class _SignInScreenState extends State<SignInScreen> {
obscureText: obscurePassword,
keyboardType: TextInputType.visiblePassword,
prefixIcon: const Icon(CupertinoIcons.lock_fill),
errorMsg: _errorMsg,
validator: (val) {
if (val!.isEmpty) {
return 'Please fill in this field';
if (val == null || val.isEmpty) {
return 'Mohon isi field ini';
}
return null;
},
@ -81,39 +101,51 @@ class _SignInScreenState extends State<SignInScreen> {
onPressed: () {
setState(() {
obscurePassword = !obscurePassword;
if (obscurePassword) {
iconPassword = CupertinoIcons.eye_fill;
} else {
iconPassword = CupertinoIcons.eye_slash_fill;
}
iconPassword = obscurePassword
? CupertinoIcons.eye_fill
: CupertinoIcons.eye_slash_fill;
});
},
icon: Icon(iconPassword),
),
),
),
// FIX: Error message satu baris di bawah kedua field
if (_errorMsg != null) ...[
const SizedBox(height: 8),
SizedBox(
width: MediaQuery.of(context).size.width * 0.9,
child: Text(
_errorMsg!,
style: const TextStyle(color: Colors.red, fontSize: 13),
),
),
],
const SizedBox(height: 20),
!signInRequired
? SizedBox(
// FIX: Tombol Sign In dan Google dinonaktifkan saat loading
if (!signInRequired) ...[
SizedBox(
width: MediaQuery.of(context).size.width * 0.5,
child: TextButton(
onPressed: () {
if (_formKey.currentState!.validate()) {
context.read<SignInBloc>().add(SignInRequired(
emailController.text,
passwordController.text));
emailController.text, passwordController.text));
}
},
style: TextButton.styleFrom(
elevation: 3.0,
backgroundColor:
Theme.of(context).colorScheme.primary,
backgroundColor: Theme.of(context).colorScheme.primary,
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(60))),
borderRadius: BorderRadius.circular(60)),
),
child: const Padding(
padding: EdgeInsets.symmetric(
horizontal: 25, vertical: 5),
padding:
EdgeInsets.symmetric(horizontal: 25, vertical: 5),
child: Text(
'Sign In',
textAlign: TextAlign.center,
@ -122,17 +154,16 @@ class _SignInScreenState extends State<SignInScreen> {
fontSize: 16,
fontWeight: FontWeight.w600),
),
)),
)
: const CircularProgressIndicator(),
),
),
),
const SizedBox(height: 16),
/// ======= Tombol Login Google ======= ///
const SizedBox(height: 20),
// Tombol Google hanya muncul ketika tidak loading
SizedBox(
width: MediaQuery.of(context).size.width * 0.5,
child: TextButton.icon(
onPressed: () {
// == Mengirim event login Google ke Bloc == //
context.read<SignInBloc>().add(GoogleSignInRequired());
},
icon: SizedBox(
@ -155,11 +186,19 @@ class _SignInScreenState extends State<SignInScreen> {
backgroundColor: Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(60),
side: const BorderSide(color: Colors.grey))),
side: const BorderSide(color: Colors.grey),
),
),
),
),
] else
const CircularProgressIndicator(),
const SizedBox(height: 20),
],
)),
),
),
),
);
}
}

View File

@ -1,11 +1,10 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:user_repository/user_repository.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:user_repository/user_repository.dart';
import '../../../components/my_text_field.dart';
import '../blocs/sign_up_bloc/sign_up_bloc.dart' as signUp;
// import '../../../blocs/authentication_bloc/authentication_bloc.dart';
import '../blocs/sign_in_bloc/sign_in_bloc.dart' as signIn;
import '../blocs/sign_up_bloc/sign_up_bloc.dart';
class SignUpScreen extends StatefulWidget {
final VoidCallback? onSignInTap;
@ -24,86 +23,60 @@ class _SignUpScreenState extends State<SignUpScreen> {
IconData iconPassword = CupertinoIcons.eye_fill;
bool obscurePassword = true;
bool signUpRequired = false;
// FIX: Hapus 5 variabel password strength yang tidak dipakai
bool containsUpperCase = false;
bool containsLowerCase = false;
bool containsNumber = false;
bool containsSpecialChar = false;
bool contains8Length = false;
@override
void dispose() {
nameController.dispose();
emailController.dispose();
passwordController.dispose();
confirmPasswordController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return BlocListener<signUp.SignUpBloc, signUp.SignUpState>(
return BlocListener<SignUpBloc, SignUpState>(
listener: (context, state) {
if (state is signUp.SignUpProcess) {
setState(() {
signUpRequired = true;
});
} else if (state is signUp.SignUpFailure) {
setState(() {
signUpRequired = false; // Matikan loading!
});
// Tampilkan pesan error lewat Snackbar biar user tahu kenapa gagal
if (state is SignUpProcess) {
setState(() => signUpRequired = true);
} else if (state is SignUpFailure) {
setState(() => signUpRequired = false);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(state.message ?? 'An error ocurred'),
backgroundColor: Colors.red),
content: Text(state.message ?? 'Terjadi kesalahan'),
backgroundColor: Colors.red,
),
);
} else if (state is signUp.SignUpSuccess) {
setState(() {
signUpRequired = false;
});
} else if (state is SignUpSuccess) {
setState(() => signUpRequired = false);
}
},
// FIX: Hapus Scaffold WelcomeScreen sudah punya Scaffold
child: Form(
key: _formKey,
child: Scaffold(
backgroundColor: Colors.white,
body: Center(
child: SingleChildScrollView(
child: Column(
children: [
const SizedBox(height: 30),
Container(
width: MediaQuery.of(context).size.width * 0.92,
padding: const EdgeInsets.symmetric(
horizontal: 20, vertical: 22),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.04),
blurRadius: 10,
offset: const Offset(0, 4),
)
],
),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 6),
const Center(
child: Text(
'Buat Akun',
style: TextStyle(
fontSize: 22, fontWeight: FontWeight.w700),
style: TextStyle(fontSize: 22, fontWeight: FontWeight.w700),
),
),
const SizedBox(height: 6),
const Center(
child: Text(
'Daftar untuk mulai monitoring',
style:
TextStyle(fontSize: 13, color: Colors.black54),
style: TextStyle(fontSize: 13, color: Colors.black54),
),
),
const SizedBox(height: 14),
const SizedBox(height: 20),
// Name
SizedBox(
width: MediaQuery.of(context).size.width * 0.9,
child: MyTextField(
// Nama
MyTextField(
controller: nameController,
hintText: 'Masukkan nama lengkap',
obscureText: false,
@ -111,20 +84,16 @@ class _SignUpScreenState extends State<SignUpScreen> {
prefixIcon: const Icon(CupertinoIcons.person_fill),
validator: (val) {
if (val == null || val.isEmpty) {
return 'Please fill in this field';
} else if (val.length > 50) {
return 'Name too long';
return 'Mohon isi field ini';
}
if (val.length > 50) return 'Nama terlalu panjang';
return null;
},
),
),
const SizedBox(height: 10),
// Email
SizedBox(
width: MediaQuery.of(context).size.width * 0.9,
child: MyTextField(
MyTextField(
controller: emailController,
hintText: 'nama@email.com',
obscureText: false,
@ -132,18 +101,19 @@ class _SignUpScreenState extends State<SignUpScreen> {
prefixIcon: const Icon(CupertinoIcons.mail_solid),
validator: (val) {
if (val == null || val.isEmpty) {
return 'Please fill in this field';
return 'Mohon isi field ini';
}
// FIX: tambah validasi format email
if (!RegExp(r'^[\w.-]+@[\w.-]+\.\w{2,}$').hasMatch(val)) {
return 'Format email tidak valid';
}
return null;
},
),
),
const SizedBox(height: 10),
// Password
SizedBox(
width: MediaQuery.of(context).size.width * 0.9,
child: MyTextField(
MyTextField(
controller: passwordController,
hintText: 'Minimal 6 karakter',
obscureText: obscurePassword,
@ -162,18 +132,19 @@ class _SignUpScreenState extends State<SignUpScreen> {
),
validator: (val) {
if (val == null || val.isEmpty) {
return 'Please fill in this field';
return 'Mohon isi field ini';
}
// FIX: tambah validasi panjang minimum
if (val.length < 6) {
return 'Password minimal 6 karakter';
}
return null;
},
),
),
const SizedBox(height: 10),
// Confirm Password
SizedBox(
width: MediaQuery.of(context).size.width * 0.9,
child: MyTextField(
MyTextField(
controller: confirmPasswordController,
hintText: 'Ulangi password',
obscureText: obscurePassword,
@ -181,42 +152,41 @@ class _SignUpScreenState extends State<SignUpScreen> {
prefixIcon: const Icon(CupertinoIcons.lock_fill),
validator: (val) {
if (val == null || val.isEmpty) {
return 'Please fill in this field';
} else if (val != passwordController.text) {
return 'Mohon isi field ini';
}
if (val != passwordController.text) {
return 'Password tidak cocok';
}
return null;
},
),
),
const SizedBox(height: 18),
const SizedBox(height: 24),
/// == Sign Up == ///
// Button
!signUpRequired
? Center(
child: SizedBox(
width:
MediaQuery.of(context).size.width * 0.5,
// Tombol Sign Up
Center(
child: !signUpRequired
? SizedBox(
width: MediaQuery.of(context).size.width * 0.5,
child: ElevatedButton(
onPressed: () {
if (_formKey.currentState!.validate()) {
MyUser myUser = MyUser.empty;
myUser.email = emailController.text;
myUser.name = nameController.text;
setState(() {
context.read<signUp.SignUpBloc>().add(
signUp.SignUpRequired(
myUser,
passwordController.text,
),
// FIX: buat MyUser langsung, jangan mutasi MyUser.empty
final myUser = MyUser(
userId: '',
email: emailController.text.trim(),
name: nameController.text.trim(),
hasActiveCart: false,
);
// FIX: add() dipanggil di luar setState
context.read<SignUpBloc>().add(
SignUpRequired(
myUser, passwordController.text),
);
});
}
},
style: TextButton.styleFrom(
style: ElevatedButton.styleFrom(
backgroundColor:
Theme.of(context).colorScheme.primary,
foregroundColor: Colors.white,
@ -224,54 +194,39 @@ class _SignUpScreenState extends State<SignUpScreen> {
borderRadius: BorderRadius.circular(60),
),
),
child: Padding(
child: const Padding(
padding: EdgeInsets.symmetric(
horizontal: 25, vertical: 5),
child: const Text(
child: Text(
'Sign Up',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600),
),
fontSize: 16, fontWeight: FontWeight.w600),
),
),
),
)
: const CircularProgressIndicator(),
const SizedBox(height: 12),
Center(
child: Text('Atau'),
),
const SizedBox(height: 12),
Center(
child: Row(
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text('Sudah punya akun? '),
TextButton(
onPressed: () {
widget.onSignInTap?.call();
},
onPressed: widget.onSignInTap,
child: const Text(
'Login di sini',
style: TextStyle(fontWeight: FontWeight.bold),
),
)
],
),
),
],
),
),
const SizedBox(height: 30),
const SizedBox(height: 16),
],
),
),
),
),
),
);
}
}

View File

@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../blocs/authentication_bloc/authentication_bloc.dart';
import 'package:user_repository/user_repository.dart';
import '../blocs/sign_in_bloc/sign_in_bloc.dart';
import '../blocs/sign_up_bloc/sign_up_bloc.dart';
import 'sign_in_screen.dart';
@ -19,86 +20,68 @@ class _WelcomeScreenState extends State<WelcomeScreen>
@override
void initState() {
tabController = TabController(initialIndex: 0, length: 2, vsync: this);
super.initState();
tabController = TabController(initialIndex: 0, length: 2, vsync: this);
}
@override
void dispose() {
tabController.dispose(); // FIX: dispose controller agar tidak memory leak
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.white,
body: SingleChildScrollView(
child: SizedBox(
height: MediaQuery.of(context).size.height,
child: Stack(
children: [
Align(
alignment: Alignment.center,
child: SizedBox(
height: MediaQuery.of(context).size.height / 1.8,
body: SafeArea(
child: Column(
children: [
const SizedBox(height: 24),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 50.0),
child: TabBar(
controller: tabController,
unselectedLabelColor: Theme.of(context)
.colorScheme
.onBackground
.withOpacity(0.5),
labelColor:
Theme.of(context).colorScheme.onBackground,
unselectedLabelColor:
Theme.of(context).colorScheme.onSurface.withOpacity(0.5),
labelColor: Theme.of(context).colorScheme.onSurface,
tabs: const [
Padding(
padding: EdgeInsets.all(12.0),
child: Text(
'Sign In',
style: TextStyle(
fontSize: 18,
),
),
child: Text('Sign In', style: TextStyle(fontSize: 18)),
),
Padding(
padding: EdgeInsets.all(12.0),
child: Text(
'Sign Up',
style: TextStyle(
fontSize: 18,
),
),
child: Text('Sign Up', style: TextStyle(fontSize: 18)),
),
],
),
),
// FIX: Expanded supaya TabBarView mengisi sisa tinggi layar
// dengan ini scroll di dalam tab bisa berjalan normal
Expanded(
child: TabBarView(
controller: tabController,
children: [
BlocProvider<SignInBloc>(
create: (context) => SignInBloc(context
.read<AuthenticationBloc>()
.userRepository),
create: (context) =>
// FIX: akses UserRepository langsung, bukan via AuthBloc
SignInBloc(context.read<UserRepository>()),
child: const SignInScreen(),
),
BlocProvider<SignUpBloc>(
create: (context) => SignUpBloc(context
.read<AuthenticationBloc>()
.userRepository),
child: SignUpScreen(onSignInTap: () {
tabController.animateTo(
0); // Ini yang bikin dia pindah ke tab Login
}),
create: (context) =>
SignUpBloc(context.read<UserRepository>()),
child: SignUpScreen(
onSignInTap: () => tabController.animateTo(0),
),
),
],
))
],
),
),
)
],
),
),
),
);
}
}

View File

@ -8,6 +8,7 @@ import '../../monitoring/evaporasi/blocs/evaporasi_bloc.dart';
import '../../monitoring/atmospheric_conditions/blocs/atmospheric_conditions_bloc.dart';
import '../widgets/notification_panel.dart';
import '../widgets/sensor_grid.dart';
import '../widgets/dashboard_charts.dart';
import 'main_drawer.dart';
class HomeScreen extends StatefulWidget {
@ -199,6 +200,9 @@ class _HomeBody extends StatelessWidget {
),
const SizedBox(height: 12),
const SensorGrid(),
const SizedBox(height: 24), // TAMBAH
const DashboardCharts(), // TAMBAH
const SizedBox(height: 16),
],
),
);

View File

@ -0,0 +1,605 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../monitoring/wind_speed/blocs/wind_speed_bloc.dart';
import '../../monitoring/evaporasi/blocs/evaporasi_bloc.dart';
import '../../monitoring/atmospheric_conditions/blocs/atmospheric_conditions_bloc.dart';
/// Period selector khusus untuk dashboard
class DashboardPeriodSelector extends StatelessWidget {
final String selected;
final ValueChanged<String> onChanged;
const DashboardPeriodSelector({
super.key,
required this.selected,
required this.onChanged,
});
static const _periods = ["Hari Ini", "Minggu Ini", "Bulan Ini"];
@override
Widget build(BuildContext context) {
return SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: _periods.map((p) {
final isSelected = p == selected;
return Padding(
padding: const EdgeInsets.only(right: 8),
child: GestureDetector(
onTap: () => onChanged(p),
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
padding:
const EdgeInsets.symmetric(horizontal: 16, vertical: 7),
decoration: BoxDecoration(
color: isSelected ? Colors.blue.shade700 : Colors.white,
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: isSelected
? Colors.blue.shade700
: Colors.grey.shade300,
),
),
child: Text(
p,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: isSelected ? Colors.white : Colors.grey.shade600,
),
),
),
),
);
}).toList(),
),
);
}
}
/// Widget utama semua grafik dashboard
class DashboardCharts extends StatefulWidget {
const DashboardCharts({super.key});
@override
State<DashboardCharts> createState() => _DashboardChartsState();
}
class _DashboardChartsState extends State<DashboardCharts> {
String _period = "Hari Ini";
void _onPeriodChanged(String p) {
setState(() => _period = p);
context.read<WindSpeedBloc>().add(WindSpeedPeriodChanged(p));
context.read<EvaporasiBloc>().add(EvaporasiPeriodChanged(p));
// AtmosphericBloc tidak punya period (hanya realtime)
}
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header + Period Selector
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Grafik Sensor',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
color: Colors.grey.shade700,
),
),
],
),
const SizedBox(height: 10),
DashboardPeriodSelector(
selected: _period,
onChanged: _onPeriodChanged,
),
const SizedBox(height: 14),
// Scrollable chart list
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
// 1. Kecepatan Angin
BlocBuilder<WindSpeedBloc, WindSpeedState>(
builder: (context, state) {
final data = switch (_period) {
"Minggu Ini" => state.weeklySpeeds,
"Bulan Ini" => state.monthlySpeeds,
_ => state.dailySpeeds,
};
return _SensorChartCard(
title: 'Kecepatan Angin',
unit: 'm/s',
color: Colors.blue.shade600,
bgColor: Colors.blue.shade50,
icon: Icons.air,
currentValue: state.currentSpeed,
data: data,
period: _period,
isLoading: state.isLoading,
);
},
),
const SizedBox(width: 12),
// 2. Evaporasi
BlocBuilder<EvaporasiBloc, EvaporasiState>(
builder: (context, state) {
final data = switch (_period) {
"Minggu Ini" => state.dailyValues, // weekly jika ada
"Bulan Ini" => state.dailyValues,
_ => state.dailyValues,
};
return _SensorChartCard(
title: 'Evaporasi',
unit: 'mm',
color: Colors.teal.shade600,
bgColor: Colors.teal.shade50,
icon: Icons.water_drop_outlined,
currentValue: state.currentValue,
data: data,
period: _period,
isLoading: state.isLoading,
);
},
),
const SizedBox(width: 12),
// 3. Tekanan Udara hanya realtime (tidak ada history)
BlocBuilder<AtmosphericConditionsBloc,
AtmosphericConditionsState>(
builder: (context, state) {
return _AtmosphericCard(state: state);
},
),
],
),
),
],
);
}
}
//
// Sensor Chart Card untuk angin & evaporasi
//
class _SensorChartCard extends StatelessWidget {
final String title;
final String unit;
final Color color;
final Color bgColor;
final IconData icon;
final double currentValue;
final List<double> data;
final String period;
final bool isLoading;
const _SensorChartCard({
required this.title,
required this.unit,
required this.color,
required this.bgColor,
required this.icon,
required this.currentValue,
required this.data,
required this.period,
required this.isLoading,
});
@override
Widget build(BuildContext context) {
return Container(
width: 260,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(20),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.06),
blurRadius: 12,
offset: const Offset(0, 4),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header
Row(
children: [
Container(
padding: const EdgeInsets.all(7),
decoration: BoxDecoration(
color: bgColor,
borderRadius: BorderRadius.circular(10),
),
child: Icon(icon, color: color, size: 18),
),
const SizedBox(width: 8),
Expanded(
child: Text(
title,
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
),
),
),
],
),
const SizedBox(height: 10),
// Nilai saat ini
if (isLoading)
Container(
height: 28,
width: 80,
decoration: BoxDecoration(
color: Colors.grey.shade200,
borderRadius: BorderRadius.circular(6),
),
)
else
Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
currentValue.toStringAsFixed(1),
style: TextStyle(
fontSize: 26,
fontWeight: FontWeight.bold,
color: color,
),
),
const SizedBox(width: 4),
Padding(
padding: const EdgeInsets.only(bottom: 3),
child: Text(
unit,
style: TextStyle(
fontSize: 12,
color: Colors.grey.shade500,
),
),
),
],
),
const SizedBox(height: 12),
// Mini chart
isLoading
? Container(
height: 70,
decoration: BoxDecoration(
color: Colors.grey.shade100,
borderRadius: BorderRadius.circular(8),
),
)
: _MiniLineChart(
data: data,
color: color,
period: period,
),
const SizedBox(height: 8),
Text(
_periodLabel(period),
style: TextStyle(fontSize: 10, color: Colors.grey.shade400),
),
],
),
);
}
String _periodLabel(String p) {
return switch (p) {
"Minggu Ini" => "Rata-rata per hari minggu ini",
"Bulan Ini" => "Rata-rata per hari bulan ini",
_ => "Rata-rata per jam hari ini",
};
}
}
//
// Atmospheric Card tekanan udara (realtime only, no history)
//
class _AtmosphericCard extends StatelessWidget {
final AtmosphericConditionsState state;
const _AtmosphericCard({required this.state});
@override
Widget build(BuildContext context) {
// Klasifikasi tekanan udara
final (label, labelColor) = _classifyPressure(state.pressure);
return Container(
width: 260,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(20),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.06),
blurRadius: 12,
offset: const Offset(0, 4),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header
Row(
children: [
Container(
padding: const EdgeInsets.all(7),
decoration: BoxDecoration(
color: Colors.purple.shade50,
borderRadius: BorderRadius.circular(10),
),
child: Icon(Icons.compress,
color: Colors.purple.shade400, size: 18),
),
const SizedBox(width: 8),
const Expanded(
child: Text(
'Kondisi Atmosfer',
style: TextStyle(fontSize: 13, fontWeight: FontWeight.w600),
),
),
],
),
const SizedBox(height: 10),
// Tekanan utama
if (state.isLoading)
Container(
height: 28,
width: 80,
decoration: BoxDecoration(
color: Colors.grey.shade200,
borderRadius: BorderRadius.circular(6),
),
)
else ...[
Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
state.pressure.toStringAsFixed(1),
style: TextStyle(
fontSize: 26,
fontWeight: FontWeight.bold,
color: Colors.purple.shade400,
),
),
const SizedBox(width: 4),
Padding(
padding: const EdgeInsets.only(bottom: 3),
child: Text(
'hPa',
style: TextStyle(fontSize: 12, color: Colors.grey.shade500),
),
),
const Spacer(),
Container(
padding:
const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: labelColor.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(8),
),
child: Text(
label,
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: labelColor,
),
),
),
],
),
const SizedBox(height: 12),
// Info tambahan: suhu, humidity, altitude
_AtmosInfoRow(
items: [
_AtmosItem(
Icons.thermostat_rounded,
'${state.temperature.toStringAsFixed(1)}°C',
'Suhu',
Colors.orange.shade400),
_AtmosItem(
Icons.water_drop_rounded,
'${state.humidity.toStringAsFixed(0)}%',
'Kelembapan',
Colors.blue.shade400),
_AtmosItem(
Icons.landscape_rounded,
'${state.altitude.toStringAsFixed(0)} m',
'Altitud',
Colors.green.shade400),
],
),
],
const SizedBox(height: 8),
Text(
'Data realtime — tidak ada history',
style: TextStyle(fontSize: 10, color: Colors.grey.shade400),
),
],
),
);
}
static (String, Color) _classifyPressure(double hpa) {
if (hpa < 1000) return ('Rendah', Colors.orange.shade600);
if (hpa > 1020) return ('Tinggi', Colors.blue.shade600);
return ('Normal', Colors.green.shade600);
}
}
class _AtmosInfoRow extends StatelessWidget {
final List<_AtmosItem> items;
const _AtmosInfoRow({required this.items});
@override
Widget build(BuildContext context) {
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: items
.map((item) => Expanded(
child: Column(
children: [
Icon(item.icon, color: item.color, size: 18),
const SizedBox(height: 3),
Text(
item.value,
style: const TextStyle(
fontSize: 12, fontWeight: FontWeight.bold),
),
Text(
item.label,
style:
TextStyle(fontSize: 10, color: Colors.grey.shade500),
),
],
),
))
.toList(),
);
}
}
class _AtmosItem {
final IconData icon;
final String value;
final String label;
final Color color;
const _AtmosItem(this.icon, this.value, this.label, this.color);
}
//
// Mini Line Chart custom painter, tanpa library tambahan
//
class _MiniLineChart extends StatelessWidget {
final List<double> data;
final Color color;
final String period;
const _MiniLineChart({
required this.data,
required this.color,
required this.period,
});
@override
Widget build(BuildContext context) {
final nonZero = data.where((v) => v > 0).toList();
if (nonZero.isEmpty) {
return SizedBox(
height: 70,
child: Center(
child: Text(
'Belum ada data',
style: TextStyle(fontSize: 11, color: Colors.grey.shade400),
),
),
);
}
return SizedBox(
height: 70,
child: CustomPaint(
painter: _LineChartPainter(data: data, color: color),
size: const Size(double.infinity, 70),
),
);
}
}
class _LineChartPainter extends CustomPainter {
final List<double> data;
final Color color;
const _LineChartPainter({required this.data, required this.color});
@override
void paint(Canvas canvas, Size size) {
if (data.isEmpty) return;
final maxVal = data.reduce((a, b) => a > b ? a : b);
if (maxVal == 0) return;
final points = <Offset>[];
for (int i = 0; i < data.length; i++) {
final x = i / (data.length - 1) * size.width;
final y = size.height - (data[i] / maxVal) * size.height;
points.add(Offset(x, y));
}
// Area fill (gradient)
final path = Path();
path.moveTo(points.first.dx, size.height);
for (final p in points) {
path.lineTo(p.dx, p.dy);
}
path.lineTo(points.last.dx, size.height);
path.close();
final fillPaint = Paint()
..shader = LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [color.withValues(alpha: 0.25), color.withValues(alpha: 0.0)],
).createShader(Rect.fromLTWH(0, 0, size.width, size.height));
canvas.drawPath(path, fillPaint);
// Line
final linePaint = Paint()
..color = color
..strokeWidth = 2
..style = PaintingStyle.stroke
..strokeCap = StrokeCap.round
..strokeJoin = StrokeJoin.round;
final linePath = Path();
linePath.moveTo(points.first.dx, points.first.dy);
for (int i = 1; i < points.length; i++) {
linePath.lineTo(points[i].dx, points[i].dy);
}
canvas.drawPath(linePath, linePaint);
// Dot di titik terakhir
final lastPoint = points.last;
canvas.drawCircle(
lastPoint,
3.5,
Paint()..color = color,
);
canvas.drawCircle(
lastPoint,
3.5,
Paint()
..color = Colors.white
..style = PaintingStyle.stroke
..strokeWidth = 1.5,
);
}
@override
bool shouldRepaint(_LineChartPainter old) =>
old.data != data || old.color != color;
}

View File

@ -23,26 +23,29 @@ class EvaporasiBloc extends Bloc<EvaporasiEvent, EvaporasiState> {
on<WatchEvaporasiStarted>(_onStarted);
on<_EvaporasiRealtimeUpdated>(_onRealtimeUpdated);
on<EvaporasiPeriodChanged>(_onPeriodChanged);
on<EvaporasiDateSelected>(_onDateSelected);
on<EvaporasiViewModeChanged>(_onViewModeChanged);
}
/// =========================
/// 🚀 START
/// =========================
Future<void> _onStarted(
WatchEvaporasiStarted event,
Emitter<EvaporasiState> emit,
) async {
emit(state.copyWith(isLoading: true));
// Ambil history yang akan dipakai untuk list + agregasi grafik.
final history = await _repository.getSensorHistory(
'evaporasi/history',
'Monitoring/History',
(json) => Evaporasi.fromJson(json),
);
final listData = List<Evaporasi>.from(history)
..sort((a, b) => a.timestamp.compareTo(b.timestamp));
final dailyGraph = TimeSeriesMapper.toDaily(
data: history,
getTime: (e) => e.timestamp,
getValue: (e) => e.evaporasi,
getValue: (e) => e.evaporasi, // sesuaikan nama field
);
final dailyTempGraph = TimeSeriesMapper.toDaily(
@ -51,17 +54,51 @@ class EvaporasiBloc extends Bloc<EvaporasiEvent, EvaporasiState> {
getValue: (e) => e.suhu,
);
// Hitung status dari data terakhir history jika ada
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(
data: history,
getTime: (e) => e.timestamp,
getValue: (e) => e.suhu,
);
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 (status, rain) = _computeWeatherStatus(lastValue);
_emitEvaporasiAlert(status, rain, lastValue);
emit(state.copyWith(
history: history,
currentValue: lastValue,
waterLevel: lastWaterLevel,
temperature: lastTemperature,
dailyValues: dailyGraph,
dailyTemperatures: dailyTempGraph,
weeklyValues: weeklyGraph,
monthlyValues: monthlyGraph,
weeklyTemperatures: weeklyTemp,
monthlyTemperatures: monthlyTemp,
chartLabels: _buildChartLabels(period: 'Hari Ini'),
weatherStatus: status,
willRain: rain,
listData: listData,
isLoading: false,
));
@ -71,21 +108,36 @@ class EvaporasiBloc extends Bloc<EvaporasiEvent, EvaporasiState> {
.listen((data) => add(_EvaporasiRealtimeUpdated(data)));
}
/// =========================
/// REALTIME
/// =========================
void _onRealtimeUpdated(
_EvaporasiRealtimeUpdated event,
Emitter<EvaporasiState> emit,
) {
final updated = List<double>.from(state.dailyValues);
final updatedTemp = List<double>.from(state.dailyTemperatures);
final index = DateTime.now().hour;
// Hindari update dobel: jika timestamp event sama dengan yang terakhir, jangan ubah bucket.
final previous = state.history.isNotEmpty ? state.history.last.timestamp : null;
final isDuplicate =
previous != null && event.data.timestamp.toUtc() == previous.toUtc();
if (index < updated.length) {
// Update bucket berdasarkan timestamp event (bukan jam lokal sekarang).
final updated = isDuplicate ? state.dailyValues : List<double>.from(state.dailyValues);
final updatedTemp = isDuplicate
? state.dailyTemperatures
: List<double>.from(state.dailyTemperatures);
final eventTime = event.data.timestamp;
final now = DateTime.now();
final isSameDayUtc = eventTime.toUtc().year == now.toUtc().year &&
eventTime.toUtc().month == now.toUtc().month &&
eventTime.toUtc().day == now.toUtc().day;
if (isSameDayUtc) {
final index = eventTime.hour;
if (index >= 0 && index < updated.length) {
updated[index] = event.data.evaporasi;
updatedTemp[index] = event.data.suhu;
}
}
final (status, rain) = _computeWeatherStatus(event.data.evaporasi);
_emitEvaporasiAlert(status, rain, event.data.evaporasi);
@ -101,20 +153,18 @@ class EvaporasiBloc extends Bloc<EvaporasiEvent, EvaporasiState> {
));
}
/// =========================
/// 📊 PERIOD
/// =========================
Future<void> _onPeriodChanged(
EvaporasiPeriodChanged event,
Emitter<EvaporasiState> emit,
) async {
emit(state.copyWith(isLoading: true, selectedPeriod: event.period));
final history = state.history;
List<double> updated;
List<double> updatedTemp;
if (event.period == "Minggu Ini") {
if (event.period == 'Minggu Ini') {
updated = TimeSeriesMapper.toWeekly(
data: history,
getTime: (e) => e.timestamp,
@ -125,7 +175,7 @@ class EvaporasiBloc extends Bloc<EvaporasiEvent, EvaporasiState> {
getTime: (e) => e.timestamp,
getValue: (e) => e.suhu,
);
} else if (event.period == "Bulan Ini") {
} else if (event.period == 'Bulan Ini') {
updated = TimeSeriesMapper.toMonthly(
data: history,
getTime: (e) => e.timestamp,
@ -149,23 +199,70 @@ class EvaporasiBloc extends Bloc<EvaporasiEvent, EvaporasiState> {
);
}
// Pertahankan status cuaca saat ini (tidak berubah karena hanya ganti periode)
emit(state.copyWith(
dailyValues: updated,
dailyTemperatures: updatedTemp,
chartLabels: _buildChartLabels(period: event.period),
viewMode: EvaporasiViewMode.period,
isLoading: false,
listData: state.listData,
history: state.history,
));
}
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,
// jangan hilangkan list
listData: state.listData,
history: state.history,
));
}
Future<void> _onViewModeChanged(
EvaporasiViewModeChanged event,
Emitter<EvaporasiState> emit,
) async {
if (event.mode == EvaporasiViewMode.period) {
add(EvaporasiPeriodChanged(state.selectedPeriod));
} else {
emit(state.copyWith(viewMode: event.mode));
}
}
@override
Future<void> close() async {
await _subscription?.cancel();
return super.close();
}
/// =========================
/// 🌤 HELPER: Status Cuaca
/// =========================
static (String status, bool willRain) _computeWeatherStatus(double value) {
if (value <= 5.0) return ('Baik', false);
if (value <= 10.0) return ('Sedang', true);
@ -185,7 +282,7 @@ class EvaporasiBloc extends Bloc<EvaporasiEvent, EvaporasiState> {
message =
'Evaporasi ${value.toStringAsFixed(1)} mm — status sedang, potensi hujan';
} else {
severity = AlertSeverity.info; // normal bersihkan alert
severity = AlertSeverity.info;
message = '';
}
@ -199,9 +296,23 @@ class EvaporasiBloc extends Bloc<EvaporasiEvent, EvaporasiState> {
),
));
}
List<String> _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');
}
}
/// INTERNAL EVENT
class _EvaporasiRealtimeUpdated extends EvaporasiEvent {
final Evaporasi data;

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,17 +1,35 @@
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 (default)
final List<double> weeklyValues;
final List<double> monthlyValues;
final List<double> dailyValues; // untuk grafik evaporasi
final List<double> dailyTemperatures; // untuk grafik suhu
final List<Evaporasi> history;
final List<double> weeklyTemperatures;
final List<double> monthlyTemperatures;
final String weatherStatus; // Baik / Sedang / Buruk
final bool willRain; // true jika status Sedang/Buruk
/// Label X-axis sesuai agregasi period
/// Contoh: Hari Ini => ["00:00","01:00",...]
/// Minggu Ini => ["Sen","Sel",...]
/// Bulan Ini => ["1","2",...]
final List<String> chartLabels;
/// Data untuk list (timestamp asli dari firebase)
final List<Evaporasi> listData;
final List<Evaporasi> history;
final String weatherStatus;
final bool willRain;
final bool isLoading;
@ -19,11 +37,19 @@ class EvaporasiState extends Equatable {
this.currentValue = 0.0,
this.temperature = 0.0,
this.waterLevel = 0.0,
this.selectedPeriod = "Hari Ini",
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 [],
this.chartLabels = const [],
this.listData = const [],
this.history = const [],
this.weatherStatus = "Baik",
this.weatherStatus = 'Baik',
this.willRain = false,
this.isLoading = true,
});
@ -33,8 +59,16 @@ class EvaporasiState extends Equatable {
double? temperature,
double? waterLevel,
String? selectedPeriod,
DateTime? selectedDate,
EvaporasiViewMode? viewMode,
List<double>? dailyValues,
List<double>? weeklyValues,
List<double>? monthlyValues,
List<double>? dailyTemperatures,
List<double>? weeklyTemperatures,
List<double>? monthlyTemperatures,
List<String>? chartLabels,
List<Evaporasi>? listData,
List<Evaporasi>? history,
String? weatherStatus,
bool? willRain,
@ -45,8 +79,16 @@ 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,
weeklyValues: weeklyValues ?? this.weeklyValues,
monthlyValues: monthlyValues ?? this.monthlyValues,
dailyTemperatures: dailyTemperatures ?? this.dailyTemperatures,
weeklyTemperatures: weeklyTemperatures ?? this.weeklyTemperatures,
monthlyTemperatures: monthlyTemperatures ?? this.monthlyTemperatures,
chartLabels: chartLabels ?? this.chartLabels,
listData: listData ?? this.listData,
history: history ?? this.history,
weatherStatus: weatherStatus ?? this.weatherStatus,
willRain: willRain ?? this.willRain,
@ -55,16 +97,25 @@ class EvaporasiState extends Equatable {
}
@override
List<Object> get props => [
List<Object?> get props => [
currentValue,
temperature,
waterLevel,
selectedPeriod,
selectedDate,
viewMode,
dailyValues,
weeklyValues,
monthlyValues,
dailyTemperatures,
weeklyTemperatures,
monthlyTemperatures,
chartLabels,
listData,
history,
weatherStatus,
willRain,
isLoading,
];
}

View File

@ -1,14 +1,21 @@
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';
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';
class EvaporasiScreen extends StatelessWidget {
class EvaporasiScreen extends StatefulWidget {
const EvaporasiScreen({super.key});
@override
State<EvaporasiScreen> createState() => _EvaporasiScreenState();
}
class _EvaporasiScreenState extends State<EvaporasiScreen> {
@override
Widget build(BuildContext context) {
return Scaffold(
@ -48,18 +55,55 @@ class EvaporasiScreen extends StatelessWidget {
const SizedBox(height: 25),
_statusCard(state),
const SizedBox(height: 25),
const Text("Tren Evaporasi & Suhu",
style:
TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
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<EvaporasiBloc>().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),
EvaporasiChartWidget(
// Chart
Builder(
builder: (context) {
final state = context.watch<EvaporasiBloc>().state;
return EvaporasiChartWidget(
dailyValues: state.dailyValues,
dailyTemperatures: state.dailyTemperatures,
period: state.selectedPeriod,
period: state.viewMode == EvaporasiViewMode.customDate
? "Tanggal Khusus"
: state.selectedPeriod,
chartLabels: state.chartLabels,
);
},
),
const SizedBox(height: 25),
const SizedBox(height: 20),
// List data (mengikuti chart: period vs custom date)
_evaporasiList(state),
const SizedBox(height: 10),
ExportPdfButton(
onExport: () => PdfExportService.evaporasi(
evaporasi: state.currentValue,
@ -160,7 +204,7 @@ class EvaporasiScreen extends StatelessWidget {
}
/// =========================
/// 🌤 STATUS CARD
/// 📈 STATUS CARD
/// =========================
Widget _statusCard(EvaporasiState state) {
Color statusColor;
@ -182,13 +226,23 @@ class EvaporasiScreen extends StatelessWidget {
break;
}
// Teks peringatan khusus evaporasi (normal/sedang/tinggi)
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(20),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(20),
border: Border.all(color: statusColor.withOpacity(0.3), width: 1.5),
),
child: Row(
children: [
@ -204,7 +258,11 @@ class EvaporasiScreen extends StatelessWidget {
),
const SizedBox(height: 4),
Text(
state.weatherStatus,
state.weatherStatus == 'Baik'
? 'Normal'
: state.weatherStatus == 'Sedang'
? 'Sedang'
: 'Tinggi',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
@ -212,9 +270,9 @@ class EvaporasiScreen extends StatelessWidget {
),
),
if (state.willRain)
const Text(
"⚠️ Potensi hujan tinggi",
style: TextStyle(
Text(
warningText,
style: const TextStyle(
fontSize: 12,
color: Colors.red,
fontWeight: FontWeight.w500,
@ -227,4 +285,158 @@ class EvaporasiScreen extends StatelessWidget {
),
);
}
/// =========================
/// 🧾 LIST DATA EVAPORASI
/// =========================
Widget _evaporasiList(EvaporasiState state) {
final data = state.viewMode == EvaporasiViewMode.customDate &&
state.selectedDate != null
? state.history
.where((e) =>
e.timestamp.year == state.selectedDate!.year &&
e.timestamp.month == state.selectedDate!.month &&
e.timestamp.day == state.selectedDate!.day)
.toList()
: state.history;
if (data.isEmpty) {
return Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(20),
),
child: const Text(
'Belum ada data evaporasi',
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: [
const Text(
'List Data Evaporasi',
style: TextStyle(fontSize: 14, fontWeight: FontWeight.bold),
),
const SizedBox(height: 12),
SizedBox(
height: 280,
child: ListView.separated(
itemCount: data.length,
itemBuilder: (context, index) {
final e = data[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),
),
),
],
),
);
}
/// =========================
/// 📅 FORMAT DATE INFO (for custom date display)
/// =========================
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);
}
}
}

View File

@ -1,51 +1,294 @@
import 'package:fl_chart/fl_chart.dart';
import 'package:flutter/material.dart';
const _tooltipBgColor = Colors.black87;
class EvaporasiChartWidget extends StatelessWidget {
final List<double> dailyValues;
final List<double> dailyTemperatures;
final String period;
final List<String> chartLabels;
const EvaporasiChartWidget({
super.key,
required this.dailyValues,
required this.dailyTemperatures,
required this.period,
required this.chartLabels,
});
double _getMaxY(List<double> data) {
if (data.isEmpty) return 10;
final max = data.reduce((a, b) => a > b ? a : b);
return (max + 5).clamp(10, 100);
double _safeValue(double value) {
if (value.isNaN || value.isInfinite) return 0.0;
return value < 0 ? 0.0 : value;
}
List<double> _safeData(List<double> data) {
return data.map((e) {
if (e.isNaN || e.isInfinite) return 0.0;
return e < 0 ? 0.0 : e;
double _minOf(List<double> values) {
if (values.isEmpty) return 0.0;
return values.map(_safeValue).reduce((a, b) => a < b ? a : b);
}
double _maxOf(List<double> values) {
if (values.isEmpty) return 0.0;
return values.map(_safeValue).reduce((a, b) => a > b ? a : b);
}
double _clampDouble(double v, double minV, double maxV) {
if (v.isNaN || v.isInfinite) return minV;
return v.clamp(minV, maxV);
}
List<FlSpot> _evapSpots() {
return dailyValues.asMap().entries.map((entry) {
return FlSpot(entry.key.toDouble(), _safeValue(entry.value));
}).toList();
}
/// Mapping suhu (°C) -> posisi Y internal agar bisa ditampilkan dalam chart
/// yang sama dengan skala evaporasi (mm).
double _tempToEvapScale({
required double temp,
required double evapMin,
required double evapMax,
required double tempMin,
required double tempMax,
}) {
// Hindari pembagian nol
final tempRange = (tempMax - tempMin);
if (tempRange.abs() < 1e-9) return evapMin;
final normalized = (temp - tempMin) / tempRange; // 0..1 (secara ideal)
final scaled = evapMin + normalized * (evapMax - evapMin);
return scaled;
}
/// Reverse mapping Y internal (skala evaporasi) -> suhu asli (°C)
double _evapScaleToTemp({
required double yEvap,
required double evapMin,
required double evapMax,
required double tempMin,
required double tempMax,
}) {
final evapRange = (evapMax - evapMin);
if (evapRange.abs() < 1e-9) return tempMin;
final normalized = (yEvap - evapMin) / evapRange;
return tempMin + normalized * (tempMax - tempMin);
}
String _getBottomLabel(int index) {
if (chartLabels.isEmpty || index < 0 || index >= chartLabels.length) {
return index.toString();
}
return chartLabels[index];
}
@override
Widget build(BuildContext context) {
final safeValues = _safeData(dailyValues);
final safeTemps = _safeData(dailyTemperatures);
// Gunakan max dari kedua dataset agar skala Y sesuai
final maxY = _getMaxY([
...safeValues,
...safeTemps,
]);
if (dailyValues.isEmpty && dailyTemperatures.isEmpty) {
return Container(
height: 280,
padding: const EdgeInsets.fromLTRB(10, 20, 20, 10),
height: 240,
alignment: Alignment.center,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(25),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
color: Colors.black.withAlpha(13),
blurRadius: 10,
offset: const Offset(0, 5),
)
],
),
child: const Text('Tidak ada data chart'),
);
}
// Hitung range masing-masing agar axis kanan (°C) masuk akal.
final evapMinRaw = _minOf(dailyValues);
final evapMaxRaw = _maxOf(dailyValues);
final tempMinRaw = _minOf(dailyTemperatures);
final tempMaxRaw = _maxOf(dailyTemperatures);
// Evaporasi mm biasanya >= 0, kita pakai min 0 agar estetik.
final evapMin = 0.0;
final evapMax = _clampDouble(evapMaxRaw * 1.3, 10.0, 100.0);
// Suhu bisa saja 0 jika data kosong; tetap aman.
final tempMin = tempMinRaw;
final tempMax = tempMaxRaw == tempMinRaw ? tempMinRaw + 1 : tempMaxRaw;
final evapSpotsAll = _evapSpots();
// Deduplicate X=hour agar garis tidak kelihatan dobel/acak.
final Map<int, double> evapByX = {};
for (final s in evapSpotsAll) {
evapByX[s.x.toInt()] = s.y;
}
final dedupEvapSpots = evapByX.entries
.toList()
..sort((a, b) => a.key.compareTo(b.key));
final Map<int, double> tempByX = {};
for (final entry in dailyTemperatures.asMap().entries) {
tempByX[entry.key] = _safeValue(entry.value);
}
final tempSpots = tempByX.entries.map((entry) {
final x = entry.key.toDouble();
final temp = entry.value;
final y = _tempToEvapScale(
temp: temp,
evapMin: evapMin,
evapMax: evapMax,
tempMin: tempMin,
tempMax: tempMax,
);
return FlSpot(x, y);
}).toList();
final evapSpots = dedupEvapSpots
.map((e) => FlSpot(e.key.toDouble(), e.value))
.toList();
if (evapSpots.isEmpty && tempSpots.isEmpty) {
return const SizedBox.shrink();
}
double _getRightTitle(double y) {
return _evapScaleToTemp(
yEvap: y,
evapMin: evapMin,
evapMax: evapMax,
tempMin: tempMin,
tempMax: tempMax,
);
}
final chart = LineChart(
LineChartData(
minY: evapMin,
maxY: evapMax,
gridData: FlGridData(show: false),
borderData: FlBorderData(show: false),
titlesData: FlTitlesData(
bottomTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: 32,
interval: 1,
getTitlesWidget: (value, meta) {
final index = value.toInt();
return Padding(
padding: const EdgeInsets.only(top: 8),
child: Text(
_getBottomLabel(index),
style: const TextStyle(color: Colors.grey, fontSize: 10),
),
);
},
),
),
leftTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: 40,
interval: (evapMax - evapMin) / 4,
getTitlesWidget: (value, meta) {
final v = value;
return Text(
'${v.toStringAsFixed(0)}',
style: const TextStyle(color: Colors.blueGrey, fontSize: 10),
);
},
),
),
rightTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: 40,
interval: (evapMax - evapMin) / 4,
getTitlesWidget: (value, meta) {
final t = _getRightTitle(value);
return Text(
'${t.toStringAsFixed(0)}',
style: const TextStyle(color: Colors.brown, fontSize: 10),
);
},
),
),
topTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
),
lineTouchData: LineTouchData(
handleBuiltInTouches: true,
touchTooltipData: LineTouchTooltipData(
getTooltipItems: (spots) {
return spots.map((spot) {
// fl_chart tidak mengekspos warna ke tooltip spot pada tipe LineBarSpot
// jadi kita tampilkan dua informasi sekaligus untuk memudahkan dosen.
final temp = _evapScaleToTemp(
yEvap: spot.y,
evapMin: evapMin,
evapMax: evapMax,
tempMin: tempMin,
tempMax: tempMax,
);
return LineTooltipItem(
'Evap: ${spot.y.toStringAsFixed(1)} mm\nSuhu: ${temp.toStringAsFixed(1)} °C',
const TextStyle(color: Colors.white, fontSize: 12),
);
}).toList();
},
),
),
lineBarsData: [
if (evapSpots.isNotEmpty)
LineChartBarData(
spots: evapSpots,
isCurved: true,
color: Colors.blue.shade700,
barWidth: 3,
dotData: FlDotData(show: false),
belowBarData: BarAreaData(
show: true,
gradient: LinearGradient(
colors: [
Colors.blue.withAlpha(64),
Colors.blue.withAlpha(13),
],
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
),
),
),
if (tempSpots.isNotEmpty)
LineChartBarData(
spots: tempSpots,
isCurved: true,
color: Colors.orange.shade700,
barWidth: 3,
dotData: FlDotData(show: false),
),
],
),
);
return Container(
height: 340,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(25),
boxShadow: [
BoxShadow(
color: Colors.black.withAlpha(13),
blurRadius: 10,
offset: const Offset(0, 5),
)
@ -55,196 +298,34 @@ class EvaporasiChartWidget extends StatelessWidget {
children: [
// Legend
Row(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
_legendItem(Colors.blue.shade700, "Evaporasi (mm)"),
const SizedBox(width: 20),
_legendItem(Colors.orange.shade600, "Suhu (°C)"),
],
),
const SizedBox(height: 10),
Expanded(
child: LineChart(
duration: const Duration(milliseconds: 600),
curve: Curves.easeOutCubic,
LineChartData(
minY: 0,
maxY: maxY,
gridData: const FlGridData(show: false),
borderData: FlBorderData(show: false),
titlesData: FlTitlesData(
show: true,
rightTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false)),
topTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false)),
leftTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false)),
bottomTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: 30,
interval: _getInterval(),
getTitlesWidget: (value, meta) {
return SideTitleWidget(
meta: meta,
space: 8,
child: Text(
_getBottomTitle(value),
style: const TextStyle(
color: Colors.grey, fontSize: 10),
),
);
},
),
),
),
lineBarsData: [
// === Garis Evaporasi ===
LineChartBarData(
spots: safeValues.asMap().entries.map((e) {
return FlSpot(e.key.toDouble(), e.value);
}).toList(),
isCurved: true,
curveSmoothness: 0.2,
preventCurveOverShooting: true,
color: Colors.blue.shade700,
barWidth: 3,
isStrokeCapRound: true,
dotData: FlDotData(
show: true,
getDotPainter: (spot, percent, bar, index) {
if (index == safeValues.length - 1) {
return FlDotCirclePainter(
radius: 5,
color: Colors.blue.shade900,
strokeWidth: 2,
strokeColor: Colors.white,
);
}
return FlDotCirclePainter(radius: 0);
},
),
belowBarData: BarAreaData(
show: true,
gradient: LinearGradient(
colors: [
Colors.blue.withOpacity(0.25),
Colors.blue.withOpacity(0.05),
Colors.transparent
],
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
),
),
),
// === Garis Suhu ===
LineChartBarData(
spots: safeTemps.asMap().entries.map((e) {
return FlSpot(e.key.toDouble(), e.value);
}).toList(),
isCurved: true,
curveSmoothness: 0.2,
preventCurveOverShooting: true,
color: Colors.orange.shade600,
barWidth: 3,
isStrokeCapRound: true,
dotData: FlDotData(
show: true,
getDotPainter: (spot, percent, bar, index) {
if (index == safeTemps.length - 1) {
return FlDotCirclePainter(
radius: 5,
color: Colors.orange.shade800,
strokeWidth: 2,
strokeColor: Colors.white,
);
}
return FlDotCirclePainter(radius: 0);
},
),
belowBarData: BarAreaData(
show: true,
gradient: LinearGradient(
colors: [
Colors.orange.withOpacity(0.25),
Colors.orange.withOpacity(0.05),
Colors.transparent
],
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
),
),
),
],
lineTouchData: LineTouchData(
handleBuiltInTouches: true,
touchTooltipData: LineTouchTooltipData(
getTooltipItems: (touchedSpots) {
return touchedSpots.map((spot) {
final isEvaporasi = spot.barIndex == 0;
final label = isEvaporasi ? "Evaporasi" : "Suhu";
final unit = isEvaporasi ? "mm" : "°C";
return LineTooltipItem(
"$label: ${spot.y.toStringAsFixed(1)} $unit",
TextStyle(
color: isEvaporasi
? Colors.blue.shade100
: Colors.orange.shade100,
fontWeight: FontWeight.bold,
),
);
}).toList();
},
),
),
),
),
),
],
),
);
}
Widget _legendItem(Color color, String label) {
return Row(
Row(
children: [
Container(
width: 12,
height: 12,
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.circular(3),
),
),
Container(width: 10, height: 10, decoration: BoxDecoration(color: Colors.blue.shade700, shape: BoxShape.circle)),
const SizedBox(width: 6),
Text(
label,
style: const TextStyle(fontSize: 12, color: Colors.grey),
const Text('Evaporasi (mm)', style: TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: Colors.blueGrey)),
],
),
Row(
children: [
Container(width: 10, height: 10, decoration: BoxDecoration(color: Colors.orange.shade700, shape: BoxShape.circle)),
const SizedBox(width: 6),
const Text('Suhu (°C)', style: TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: Colors.brown)),
],
),
],
),
const SizedBox(height: 8),
Expanded(
child: ClipRRect(
borderRadius: BorderRadius.circular(25),
child: chart,
),
),
],
),
);
}
String _getBottomTitle(double value) {
int index = value.toInt();
final len = dailyValues.length;
if (index < 0 || index >= len) return '';
if (period == "Hari Ini") {
return index % 4 == 0 ? "${index.toString().padLeft(2, '0')}:00" : "";
} else if (period == "Minggu Ini") {
const days = ["Sen", "Sel", "Rab", "Kam", "Jum", "Sab", "Min"];
return days[index % 7];
} else {
return (index + 1) % 5 == 0 ? "${index + 1}" : "";
}
}
double _getInterval() {
if (period == "Hari Ini") return 1;
if (period == "Minggu Ini") return 1;
return 1;
}
}

View File

@ -0,0 +1,237 @@
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: 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(),
),
);
}

View File

@ -0,0 +1,54 @@
import 'package:flutter/material.dart';
class EvaporasiDateSearchBar extends StatefulWidget {
final String initialQuery;
final ValueChanged<String> onQueryChanged;
const EvaporasiDateSearchBar({
super.key,
required this.initialQuery,
required this.onQueryChanged,
});
@override
State<EvaporasiDateSearchBar> createState() => _EvaporasiDateSearchBarState();
}
class _EvaporasiDateSearchBarState extends State<EvaporasiDateSearchBar> {
late final TextEditingController _controller;
@override
void initState() {
super.initState();
_controller = TextEditingController(text: widget.initialQuery);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return TextField(
controller: _controller,
decoration: InputDecoration(
hintText: 'Cari tanggal (dd/MM/yyyy)',
prefixIcon: const Icon(Icons.search),
suffixIcon: _controller.text.isNotEmpty
? IconButton(
icon: const Icon(Icons.clear),
onPressed: () => setState(() => _controller.clear()),
)
: null,
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
isDense: true,
),
onChanged: (v) {
widget.onQueryChanged(v);
setState(() {});
},
);
}
}

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,62 @@ 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
final bloc = context.read<EvaporasiBloc>();
bloc.add(
const EvaporasiViewModeChanged(EvaporasiViewMode.customDate),
);
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (_) => BlocProvider.value(
value: bloc,
child: const EvaporasiDatePicker(),
),
);
},
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

@ -19,26 +19,115 @@ class Evaporasi {
);
factory Evaporasi.fromJson(Map<dynamic, dynamic> json) {
final now = DateTime.now();
DateTime timestamp = now;
double toDoubleSafe(dynamic v) {
if (v is num) return v.toDouble();
if (v is String) {
final s = v.trim();
// dukung format seperti "12.3 cm" / "12,3" / "-"
final normalized = s.replaceAll(',', '.');
final match = RegExp(r'[-+]?\d*\.?\d+').firstMatch(normalized);
if (match != null) {
return double.tryParse(match.group(0)!) ?? 0;
}
return double.tryParse(normalized) ?? 0;
}
return 0;
}
final evaporasiVal = toDoubleSafe(
json['evaporasi_mm'] ??
json['evaporasi'] ??
json['evaporasiMm'] ??
json['evaporation_mm'] ??
json['evap_mm'] ??
json['evaporasi_mm_'] ??
json['evaporasi_mm '] ??
json['evaporasi_value'] ??
json['evaporasi_mm_k'] ??
json['evaporasi_k'],
);
final suhuVal = toDoubleSafe(
json['suhu'] ??
json['suhu_air'] ??
json['suhuAir'] ??
json['temp'] ??
json['temperature'],
);
// Banyak kemungkinan penamaan field tinggi air.
// Pakai beberapa alias agar tidak default 0.
final tinggiVal = toDoubleSafe(
json['tinggi_air_cm'] ??
json['tinggi_air'] ??
json['tinggiAir'] ??
json['tinggiAir_cm'] ??
json['tinggi_air_cm_'] ??
json['tinggi_air_cm '] ??
json['water_level'] ??
json['waterLevel'] ??
json['tinggi_air_m'] ??
json['tinggiAir_m'],
);
// Default timestamp: fallback now (kalau field waktu tidak ada).
// Catatan: untuk Firebase seharusnya timestamp dikirim konsisten (ms atau ISO string).
DateTime timestamp = DateTime.now();
// dukung beberapa kemungkinan penamaan timestamp
final rawTimestamp =
json['timestamp'] ?? json['time'] ?? json['waktu'] ?? json['datetime'];
if (rawTimestamp != null) {
if (rawTimestamp is int) {
timestamp = DateTime.fromMillisecondsSinceEpoch(rawTimestamp);
} else if (rawTimestamp is double) {
timestamp = DateTime.fromMillisecondsSinceEpoch(rawTimestamp.toInt());
} else if (rawTimestamp is String) {
final s = rawTimestamp.trim();
// jika string berupa angka (ms/seconds)
final unixMs = int.tryParse(s);
if (unixMs != null) {
// heuristik: kalau nilainya terlalu kecil kemungkinan seconds
if (unixMs < 1000000000000) {
timestamp = DateTime.fromMillisecondsSinceEpoch(unixMs * 1000);
} else {
timestamp = DateTime.fromMillisecondsSinceEpoch(unixMs);
}
} else {
final parsed = DateTime.tryParse(s);
if (parsed != null) {
timestamp = parsed;
}
}
}
} else {
// legacy fallback dari field terpisah
final datetimeStr = json['datetime'] as String?;
if (datetimeStr != null) {
final parsed = DateTime.tryParse(datetimeStr);
if (parsed != null) timestamp = parsed;
}
final waktuStr = json['waktu'] as String?;
if (waktuStr != null) {
if (waktuStr != null && datetimeStr == null) {
final parts = waktuStr.split(':');
if (parts.length >= 2) {
final jam = int.tryParse(parts[0]) ?? 0;
final menit = int.tryParse(parts[1]) ?? 0;
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);
}
}
}
return Evaporasi(
evaporasi: (json['evaporasi'] ?? 0).toDouble(),
suhu: (json['suhu_air'] ?? 0).toDouble(),
tinggiAir: (json['tinggi_air'] ?? 0).toDouble(),
/// 🔥 bikin timestamp dari jam & menit
evaporasi: evaporasiVal,
suhu: suhuVal,
tinggiAir: tinggiVal,
timestamp: timestamp,
);
}

View File

@ -10,11 +10,9 @@ class FirebaseUserRepo implements UserRepository {
final FirebaseAuth _firebaseAuth;
final userCollection = FirebaseFirestore.instance.collection('users');
FirebaseUserRepo({
FirebaseAuth? firebaseAuth,
}) : _firebaseAuth = firebaseAuth ?? FirebaseAuth.instance;
FirebaseUserRepo({FirebaseAuth? firebaseAuth})
: _firebaseAuth = firebaseAuth ?? FirebaseAuth.instance;
/// == Melakukan Implement User == ///
@override
Stream<MyUser?> get user {
return _firebaseAuth.authStateChanges().flatMap((firebaseUser) async* {
@ -27,16 +25,9 @@ class FirebaseUserRepo implements UserRepository {
yield MyUser.fromEntity(
MyUserEntity.fromDocument(userData.data()!));
} else {
// User baru yang belum ada data di Firestore, tunggu sebentar
await Future.delayed(const Duration(milliseconds: 500));
final retryData = await userCollection.doc(firebaseUser.uid).get();
if (retryData.exists && retryData.data() != null) {
yield MyUser.fromEntity(
MyUserEntity.fromDocument(retryData.data()!));
} else {
// Data Firestore belum ada (seharusnya tidak terjadi dengan flow yang sudah diperbaiki)
yield MyUser.empty;
}
}
} catch (e) {
log('Error fetching user data: $e');
yield MyUser.empty;
@ -45,7 +36,6 @@ class FirebaseUserRepo implements UserRepository {
});
}
/// == Melakukan Implement Sign in == ///
@override
Future<void> signIn(String email, String password) async {
try {
@ -57,7 +47,6 @@ class FirebaseUserRepo implements UserRepository {
}
}
/// == Melakukan Implement Sign Up == ///
@override
Future<MyUser> signUp(MyUser myUser, String password) async {
try {
@ -71,7 +60,6 @@ class FirebaseUserRepo implements UserRepository {
}
}
/// == Melakukan Implement Log Out == ///
@override
Future<void> logOut() async {
final GoogleSignIn googleSignIn = GoogleSignIn();
@ -85,27 +73,26 @@ class FirebaseUserRepo implements UserRepository {
await userCollection
.doc(myUser.userId)
.set(myUser.toEntity().toDocument());
// Tunggu sebentar agar data tersimpan dengan baik sebelum stream update
await Future.delayed(const Duration(milliseconds: 500));
} catch (e) {
log(e.toString());
rethrow;
}
}
/// == Sign in with Google == ///
@override
Future<void> signInWithGoogle() async {
try {
UserCredential userCredential;
if (kIsWeb) {
final googleProvider = GoogleAuthProvider();
await _firebaseAuth.signInWithPopup(googleProvider);
userCredential = await _firebaseAuth.signInWithPopup(googleProvider);
} else {
final GoogleSignIn googleSignIn = GoogleSignIn();
final googleUser = await googleSignIn.signIn();
if (googleUser == null) {
throw Exception("cancelled");
throw Exception('cancelled');
}
final GoogleSignInAuthentication googleAuth =
@ -115,7 +102,22 @@ class FirebaseUserRepo implements UserRepository {
accessToken: googleAuth.accessToken,
idToken: googleAuth.idToken,
);
await _firebaseAuth.signInWithCredential(credential);
userCredential = await _firebaseAuth.signInWithCredential(credential);
}
// FIX: Cek apakah user sudah punya data Firestore
// Jika belum (user baru), buat dokumen sekarang juga
final firebaseUser = userCredential.user!;
final doc = await userCollection.doc(firebaseUser.uid).get();
if (!doc.exists) {
final newUser = MyUser(
userId: firebaseUser.uid,
email: firebaseUser.email ?? '',
name: firebaseUser.displayName ?? '',
hasActiveCart: false,
);
await setUserData(newUser);
}
} catch (e) {
log('Google sign-in error: $e');

View File

@ -1,4 +1,3 @@
import '../entities/entities.dart';
class MyUser {
@ -12,7 +11,6 @@ class MyUser {
required this.email,
required this.name,
required this.hasActiveCart,
});
static final empty = MyUser(
@ -40,9 +38,19 @@ class MyUser {
);
}
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is MyUser &&
runtimeType == other.runtimeType &&
userId == other.userId &&
email == other.email;
@override
int get hashCode => Object.hash(userId, email);
@override
String toString() {
return 'MyUser: $userId, email: $email, name: $name, hasActiveCart: $hasActiveCart}';
}
}

View File

@ -695,6 +695,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
@ -740,6 +748,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:
app_settings: ^7.0.0
bloc: ^8.1.0
@ -16,6 +17,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
@ -34,6 +36,7 @@ dev_dependencies:
flutter_lints: ^3.0.0
flutter_test:
sdk: flutter
flutter:
uses-material-design: true
assets:

View File

@ -5,12 +5,11 @@
// gestures. You can also use WidgetTester to find child widgets in the widget
// tree, read text, and verify that the values of widget properties are correct.
import 'package:firebase_database/firebase_database.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:klimatologiot/app.dart';
import 'package:monitoring_repository/monitoring_repository.dart';
import 'package:user_repository/user_repository.dart';
import '../lib/app.dart';
// import 'package:klimatologiot/main.dart';
@ -49,31 +48,38 @@ class FakeUserRepository implements UserRepository {
}
class FakeMonitoringRepository implements MonitoringRepository {
get _db => null;
// Satu fungsi untuk semua jenis sensor
// Kamu cukup masukkan "path" database-nya saja
@override
// Jika ingin mengambil data sekali saja (bukan stream)
Future<DataSnapshot> getSensorSnapshot(String path) async {
return await _db.ref(path).get();
Future<T> getSensorSnapshot<T>(
String path,
T Function(Map<dynamic, dynamic>) mapper,
) async {
return mapper(<dynamic, dynamic>{});
}
@override
Stream<DatabaseEvent> getSensorStream(String path) {
// TODO: implement getSensorStream
throw UnimplementedError();
Stream<T> getSensorStream<T>(
String path,
T Function(Map<dynamic, dynamic>) mapper,
) {
return const Stream.empty();
}
@override
Future<List<T>> getSensorHistory<T>(
String path,
T Function(Map<dynamic, dynamic>) mapper,
) async {
return <T>[];
}
}
void main() {
testWidgets('App loads', (WidgetTester tester) async {
final fakeRepo = FakeUserRepository();
final fakeMonitoring = FakeUserRepository();
final fakeMonitoring = FakeMonitoringRepository();
// Build our app and trigger a frame.
await tester.pumpWidget(
MyApp(fakeRepo, fakeMonitoring as MonitoringRepository),
MyApp(fakeRepo, fakeMonitoring),
);
await tester.pump();