Merge branch 'branch_percobaan'
This commit is contained in:
commit
c0e7a1f1d6
17
TODO.md
17
TODO.md
|
|
@ -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
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,9 @@
|
||||||
|
|
||||||
# The following line activates a set of recommended lints for Flutter apps,
|
# The following line activates a set of recommended lints for Flutter apps,
|
||||||
# packages, and plugins designed to encourage good coding practices.
|
# packages, and plugins designed to encourage good coding practices.
|
||||||
|
analyzer:
|
||||||
|
errors:
|
||||||
|
unused_local_variable: ignore
|
||||||
include: package:flutter_lints/flutter.yaml
|
include: package:flutter_lints/flutter.yaml
|
||||||
|
|
||||||
linter:
|
linter:
|
||||||
|
|
|
||||||
|
|
@ -11,25 +11,23 @@ class MyAppView extends StatelessWidget {
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return MaterialApp(
|
return MaterialApp(
|
||||||
title: 'Klimatologi',
|
title: 'Klimatologi',
|
||||||
debugShowCheckedModeBanner: false,
|
debugShowCheckedModeBanner: false,
|
||||||
theme: ThemeData(
|
theme: ThemeData(
|
||||||
colorScheme: ColorScheme.light(
|
colorScheme: ColorScheme.light(
|
||||||
background: Colors.grey.shade100,
|
surface: Colors.grey.shade100,
|
||||||
onBackground: Colors.black,
|
onSurface: Colors.black,
|
||||||
primary: Colors.blue,
|
primary: Colors.blue,
|
||||||
onPrimary: Colors.white
|
onPrimary: Colors.white),
|
||||||
),
|
),
|
||||||
),
|
home: BlocBuilder<AuthenticationBloc, AuthenticationState>(
|
||||||
home: BlocBuilder<AuthenticationBloc, AuthenticationState>(
|
builder: ((context, state) {
|
||||||
builder: ((context, state){
|
if (state.status == AuthenticationStatus.authenticated) {
|
||||||
if(state.status == AuthenticationStatus.authenticated){
|
return HomeScreen();
|
||||||
return HomeScreen();
|
|
||||||
} else {
|
} else {
|
||||||
return WelcomeScreen();
|
return WelcomeScreen();
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
)
|
));
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -7,20 +7,19 @@ import 'package:user_repository/user_repository.dart';
|
||||||
part 'authentication_event.dart';
|
part 'authentication_event.dart';
|
||||||
part 'authentication_state.dart';
|
part 'authentication_state.dart';
|
||||||
|
|
||||||
class AuthenticationBloc extends Bloc<AuthenticationEvent, AuthenticationState> {
|
class AuthenticationBloc
|
||||||
|
extends Bloc<AuthenticationEvent, AuthenticationState> {
|
||||||
final UserRepository userRepository;
|
final UserRepository userRepository;
|
||||||
late final StreamSubscription<MyUser?> _userSubscription;
|
late final StreamSubscription<MyUser?> _userSubscription;
|
||||||
|
|
||||||
AuthenticationBloc({
|
AuthenticationBloc({required this.userRepository})
|
||||||
required this.userRepository
|
: super(const AuthenticationState.unknown()) {
|
||||||
}) : super(const AuthenticationState.unknown()) {
|
|
||||||
_userSubscription = userRepository.user.listen((user) {
|
_userSubscription = userRepository.user.listen((user) {
|
||||||
add(AuthenticationUserChanged(user));
|
add(AuthenticationUserChanged(user));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
on<AuthenticationUserChanged>((event, emit) {
|
on<AuthenticationUserChanged>((event, emit) {
|
||||||
if(event.user != MyUser.empty) {
|
if (event.user != null && event.user != MyUser.empty) {
|
||||||
emit(AuthenticationState.authenticated(event.user!));
|
emit(AuthenticationState.authenticated(event.user!));
|
||||||
} else {
|
} else {
|
||||||
emit(AuthenticationState.unauthenticated());
|
emit(AuthenticationState.unauthenticated());
|
||||||
|
|
|
||||||
|
|
@ -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
|
/// 🧠 HELPER
|
||||||
/// =========================
|
/// =========================
|
||||||
|
/// Sinkronisasi tanggal untuk menghindari mismatch akibat timezone (UTC vs local).
|
||||||
|
/// Kita bandingkan berdasarkan UTC.
|
||||||
static bool _isSameDay(DateTime a, DateTime b) {
|
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) {
|
static List<double> smooth(List<double> data) {
|
||||||
if (data.length < 3) return data;
|
if (data.length < 3) return data;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ class SignInBloc extends Bloc<SignInEvent, SignInState> {
|
||||||
emit(SignInProcess());
|
emit(SignInProcess());
|
||||||
try {
|
try {
|
||||||
await _userRepository.signIn(event.email, event.password);
|
await _userRepository.signIn(event.email, event.password);
|
||||||
|
emit(SignInSuccess());
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
emit(SignInFailure());
|
emit(SignInFailure());
|
||||||
}
|
}
|
||||||
|
|
@ -25,7 +26,6 @@ class SignInBloc extends Bloc<SignInEvent, SignInState> {
|
||||||
emit(SignInSuccess());
|
emit(SignInSuccess());
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
emit(SignInFailure());
|
emit(SignInFailure());
|
||||||
emit(SignInInitial());
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -21,47 +21,68 @@ class _SignInScreenState extends State<SignInScreen> {
|
||||||
bool obscurePassword = true;
|
bool obscurePassword = true;
|
||||||
String? _errorMsg;
|
String? _errorMsg;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
emailController.dispose();
|
||||||
|
passwordController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return BlocListener<SignInBloc, SignInState>(
|
return BlocListener<SignInBloc, SignInState>(
|
||||||
listener: (context, state) {
|
listener: (context, state) {
|
||||||
if (state is SignInProcess) {
|
if (state is SignInProcess) {
|
||||||
setState(() {
|
setState(() => signInRequired = true);
|
||||||
signInRequired = true;
|
|
||||||
});
|
|
||||||
} else {
|
} else {
|
||||||
setState(() {
|
setState(() => signInRequired = false);
|
||||||
signInRequired = false;
|
|
||||||
});
|
|
||||||
if (state is SignInFailure) {
|
if (state is SignInFailure) {
|
||||||
setState(() {
|
setState(() => _errorMsg = 'Email atau password salah. Coba lagi.');
|
||||||
// PERUBAHAN: Pesan error lebih umum karena Google gagal belum tentu soal password
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
_errorMsg = 'Sign in failed. Please try again.';
|
SnackBar(
|
||||||
});
|
content: Text(_errorMsg!),
|
||||||
|
backgroundColor: Colors.red,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else if (state is SignInSuccess) {
|
||||||
|
setState(() => _errorMsg = null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
child: Form(
|
child: SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
|
child: Form(
|
||||||
key: _formKey,
|
key: _formKey,
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
|
|
||||||
|
// Email field
|
||||||
SizedBox(
|
SizedBox(
|
||||||
width: MediaQuery.of(context).size.width * 0.9,
|
width: MediaQuery.of(context).size.width * 0.9,
|
||||||
child: MyTextField(
|
child: MyTextField(
|
||||||
controller: emailController,
|
controller: emailController,
|
||||||
hintText: 'Email',
|
hintText: 'Email',
|
||||||
obscureText: false,
|
obscureText: false,
|
||||||
keyboardType: TextInputType.emailAddress,
|
keyboardType: TextInputType.emailAddress,
|
||||||
prefixIcon: const Icon(CupertinoIcons.mail_solid),
|
prefixIcon: const Icon(CupertinoIcons.mail_solid),
|
||||||
errorMsg: _errorMsg,
|
// FIX: tidak lagi pass errorMsg ke field individual
|
||||||
validator: (val) {
|
validator: (val) {
|
||||||
if (val!.isEmpty) {
|
if (val == null || val.isEmpty) {
|
||||||
return 'Please fill in this field';
|
return 'Mohon isi field ini';
|
||||||
}
|
}
|
||||||
return null;
|
// 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),
|
const SizedBox(height: 10),
|
||||||
|
|
||||||
|
// Password field
|
||||||
SizedBox(
|
SizedBox(
|
||||||
width: MediaQuery.of(context).size.width * 0.9,
|
width: MediaQuery.of(context).size.width * 0.9,
|
||||||
child: MyTextField(
|
child: MyTextField(
|
||||||
|
|
@ -70,10 +91,9 @@ class _SignInScreenState extends State<SignInScreen> {
|
||||||
obscureText: obscurePassword,
|
obscureText: obscurePassword,
|
||||||
keyboardType: TextInputType.visiblePassword,
|
keyboardType: TextInputType.visiblePassword,
|
||||||
prefixIcon: const Icon(CupertinoIcons.lock_fill),
|
prefixIcon: const Icon(CupertinoIcons.lock_fill),
|
||||||
errorMsg: _errorMsg,
|
|
||||||
validator: (val) {
|
validator: (val) {
|
||||||
if (val!.isEmpty) {
|
if (val == null || val.isEmpty) {
|
||||||
return 'Please fill in this field';
|
return 'Mohon isi field ini';
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
|
|
@ -81,85 +101,104 @@ class _SignInScreenState extends State<SignInScreen> {
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
setState(() {
|
setState(() {
|
||||||
obscurePassword = !obscurePassword;
|
obscurePassword = !obscurePassword;
|
||||||
if (obscurePassword) {
|
iconPassword = obscurePassword
|
||||||
iconPassword = CupertinoIcons.eye_fill;
|
? CupertinoIcons.eye_fill
|
||||||
} else {
|
: CupertinoIcons.eye_slash_fill;
|
||||||
iconPassword = CupertinoIcons.eye_slash_fill;
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
icon: Icon(iconPassword),
|
icon: Icon(iconPassword),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 20),
|
|
||||||
!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));
|
|
||||||
}
|
|
||||||
},
|
|
||||||
style: TextButton.styleFrom(
|
|
||||||
elevation: 3.0,
|
|
||||||
backgroundColor:
|
|
||||||
Theme.of(context).colorScheme.primary,
|
|
||||||
foregroundColor: Colors.white,
|
|
||||||
shape: RoundedRectangleBorder(
|
|
||||||
borderRadius: BorderRadius.circular(60))),
|
|
||||||
child: const Padding(
|
|
||||||
padding: EdgeInsets.symmetric(
|
|
||||||
horizontal: 25, vertical: 5),
|
|
||||||
child: Text(
|
|
||||||
'Sign In',
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
style: TextStyle(
|
|
||||||
color: Colors.white,
|
|
||||||
fontSize: 16,
|
|
||||||
fontWeight: FontWeight.w600),
|
|
||||||
),
|
|
||||||
)),
|
|
||||||
)
|
|
||||||
: const CircularProgressIndicator(),
|
|
||||||
|
|
||||||
/// ======= Tombol Login Google ======= ///
|
// 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),
|
const SizedBox(height: 20),
|
||||||
SizedBox(
|
|
||||||
width: MediaQuery.of(context).size.width * 0.5,
|
// FIX: Tombol Sign In dan Google dinonaktifkan saat loading
|
||||||
child: TextButton.icon(
|
if (!signInRequired) ...[
|
||||||
onPressed: () {
|
SizedBox(
|
||||||
// == Mengirim event login Google ke Bloc == //
|
width: MediaQuery.of(context).size.width * 0.5,
|
||||||
context.read<SignInBloc>().add(GoogleSignInRequired());
|
child: TextButton(
|
||||||
},
|
onPressed: () {
|
||||||
icon: SizedBox(
|
if (_formKey.currentState!.validate()) {
|
||||||
height: 20,
|
context.read<SignInBloc>().add(SignInRequired(
|
||||||
width: 20,
|
emailController.text, passwordController.text));
|
||||||
child: Image.asset(
|
}
|
||||||
'images/google-icon-logo.png',
|
},
|
||||||
fit: BoxFit.contain,
|
style: TextButton.styleFrom(
|
||||||
|
elevation: 3.0,
|
||||||
|
backgroundColor: Theme.of(context).colorScheme.primary,
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(60)),
|
||||||
|
),
|
||||||
|
child: const Padding(
|
||||||
|
padding:
|
||||||
|
EdgeInsets.symmetric(horizontal: 25, vertical: 5),
|
||||||
|
child: Text(
|
||||||
|
'Sign In',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w600),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
label: const Text(
|
),
|
||||||
'Sign In with Google',
|
const SizedBox(height: 16),
|
||||||
style: TextStyle(
|
|
||||||
color: Colors.black,
|
// Tombol Google — hanya muncul ketika tidak loading
|
||||||
fontSize: 16,
|
SizedBox(
|
||||||
fontWeight: FontWeight.w600),
|
width: MediaQuery.of(context).size.width * 0.5,
|
||||||
),
|
child: TextButton.icon(
|
||||||
style: TextButton.styleFrom(
|
onPressed: () {
|
||||||
|
context.read<SignInBloc>().add(GoogleSignInRequired());
|
||||||
|
},
|
||||||
|
icon: SizedBox(
|
||||||
|
height: 20,
|
||||||
|
width: 20,
|
||||||
|
child: Image.asset(
|
||||||
|
'images/google-icon-logo.png',
|
||||||
|
fit: BoxFit.contain,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
label: const Text(
|
||||||
|
'Sign In with Google',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.black,
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w600),
|
||||||
|
),
|
||||||
|
style: TextButton.styleFrom(
|
||||||
elevation: 3.0,
|
elevation: 3.0,
|
||||||
backgroundColor: Colors.white,
|
backgroundColor: Colors.white,
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(60),
|
borderRadius: BorderRadius.circular(60),
|
||||||
side: const BorderSide(color: Colors.grey))),
|
side: const BorderSide(color: Colors.grey),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
] else
|
||||||
|
const CircularProgressIndicator(),
|
||||||
|
|
||||||
|
const SizedBox(height: 20),
|
||||||
],
|
],
|
||||||
)),
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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/cupertino.dart';
|
||||||
import 'package:flutter/material.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 '../../../components/my_text_field.dart';
|
||||||
import '../blocs/sign_up_bloc/sign_up_bloc.dart' as signUp;
|
import '../blocs/sign_up_bloc/sign_up_bloc.dart';
|
||||||
// import '../../../blocs/authentication_bloc/authentication_bloc.dart';
|
|
||||||
import '../blocs/sign_in_bloc/sign_in_bloc.dart' as signIn;
|
|
||||||
|
|
||||||
class SignUpScreen extends StatefulWidget {
|
class SignUpScreen extends StatefulWidget {
|
||||||
final VoidCallback? onSignInTap;
|
final VoidCallback? onSignInTap;
|
||||||
|
|
@ -24,251 +23,207 @@ class _SignUpScreenState extends State<SignUpScreen> {
|
||||||
IconData iconPassword = CupertinoIcons.eye_fill;
|
IconData iconPassword = CupertinoIcons.eye_fill;
|
||||||
bool obscurePassword = true;
|
bool obscurePassword = true;
|
||||||
bool signUpRequired = false;
|
bool signUpRequired = false;
|
||||||
|
// FIX: Hapus 5 variabel password strength yang tidak dipakai
|
||||||
|
|
||||||
bool containsUpperCase = false;
|
@override
|
||||||
bool containsLowerCase = false;
|
void dispose() {
|
||||||
bool containsNumber = false;
|
nameController.dispose();
|
||||||
bool containsSpecialChar = false;
|
emailController.dispose();
|
||||||
bool contains8Length = false;
|
passwordController.dispose();
|
||||||
|
confirmPasswordController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return BlocListener<signUp.SignUpBloc, signUp.SignUpState>(
|
return BlocListener<SignUpBloc, SignUpState>(
|
||||||
listener: (context, state) {
|
listener: (context, state) {
|
||||||
if (state is signUp.SignUpProcess) {
|
if (state is SignUpProcess) {
|
||||||
setState(() {
|
setState(() => signUpRequired = true);
|
||||||
signUpRequired = true;
|
} else if (state is SignUpFailure) {
|
||||||
});
|
setState(() => signUpRequired = false);
|
||||||
} else if (state is signUp.SignUpFailure) {
|
|
||||||
setState(() {
|
|
||||||
signUpRequired = false; // Matikan loading!
|
|
||||||
});
|
|
||||||
// Tampilkan pesan error lewat Snackbar biar user tahu kenapa gagal
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
content: Text(state.message ?? 'An error ocurred'),
|
content: Text(state.message ?? 'Terjadi kesalahan'),
|
||||||
backgroundColor: Colors.red),
|
backgroundColor: Colors.red,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
} else if (state is signUp.SignUpSuccess) {
|
} else if (state is SignUpSuccess) {
|
||||||
setState(() {
|
setState(() => signUpRequired = false);
|
||||||
signUpRequired = false;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
// FIX: Hapus Scaffold — WelcomeScreen sudah punya Scaffold
|
||||||
child: Form(
|
child: Form(
|
||||||
key: _formKey,
|
key: _formKey,
|
||||||
child: Scaffold(
|
child: SingleChildScrollView(
|
||||||
backgroundColor: Colors.white,
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 20),
|
||||||
body: Center(
|
child: Column(
|
||||||
child: SingleChildScrollView(
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
child: Column(
|
children: [
|
||||||
children: [
|
const Center(
|
||||||
const SizedBox(height: 30),
|
child: Text(
|
||||||
Container(
|
'Buat Akun',
|
||||||
width: MediaQuery.of(context).size.width * 0.92,
|
style: TextStyle(fontSize: 22, fontWeight: FontWeight.w700),
|
||||||
padding: const EdgeInsets.symmetric(
|
),
|
||||||
horizontal: 20, vertical: 22),
|
),
|
||||||
decoration: BoxDecoration(
|
const SizedBox(height: 6),
|
||||||
color: Colors.white,
|
const Center(
|
||||||
borderRadius: BorderRadius.circular(12),
|
child: Text(
|
||||||
boxShadow: [
|
'Daftar untuk mulai monitoring',
|
||||||
BoxShadow(
|
style: TextStyle(fontSize: 13, color: Colors.black54),
|
||||||
color: Colors.black.withOpacity(0.04),
|
),
|
||||||
blurRadius: 10,
|
),
|
||||||
offset: const Offset(0, 4),
|
const SizedBox(height: 20),
|
||||||
)
|
|
||||||
],
|
|
||||||
),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
const SizedBox(height: 6),
|
|
||||||
const Center(
|
|
||||||
child: Text(
|
|
||||||
'Buat Akun',
|
|
||||||
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),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 14),
|
|
||||||
|
|
||||||
// Name
|
// Nama
|
||||||
SizedBox(
|
MyTextField(
|
||||||
width: MediaQuery.of(context).size.width * 0.9,
|
controller: nameController,
|
||||||
child: MyTextField(
|
hintText: 'Masukkan nama lengkap',
|
||||||
controller: nameController,
|
obscureText: false,
|
||||||
hintText: 'Masukkan nama lengkap',
|
keyboardType: TextInputType.name,
|
||||||
obscureText: false,
|
prefixIcon: const Icon(CupertinoIcons.person_fill),
|
||||||
keyboardType: TextInputType.name,
|
validator: (val) {
|
||||||
prefixIcon: const Icon(CupertinoIcons.person_fill),
|
if (val == null || val.isEmpty) {
|
||||||
validator: (val) {
|
return 'Mohon isi field ini';
|
||||||
if (val == null || val.isEmpty) {
|
}
|
||||||
return 'Please fill in this field';
|
if (val.length > 50) return 'Nama terlalu panjang';
|
||||||
} else if (val.length > 50) {
|
return null;
|
||||||
return 'Name too long';
|
},
|
||||||
}
|
),
|
||||||
return null;
|
const SizedBox(height: 10),
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 10),
|
|
||||||
|
|
||||||
// Email
|
// Email
|
||||||
SizedBox(
|
MyTextField(
|
||||||
width: MediaQuery.of(context).size.width * 0.9,
|
controller: emailController,
|
||||||
child: MyTextField(
|
hintText: 'nama@email.com',
|
||||||
controller: emailController,
|
obscureText: false,
|
||||||
hintText: 'nama@email.com',
|
keyboardType: TextInputType.emailAddress,
|
||||||
obscureText: false,
|
prefixIcon: const Icon(CupertinoIcons.mail_solid),
|
||||||
keyboardType: TextInputType.emailAddress,
|
validator: (val) {
|
||||||
prefixIcon: const Icon(CupertinoIcons.mail_solid),
|
if (val == null || val.isEmpty) {
|
||||||
validator: (val) {
|
return 'Mohon isi field ini';
|
||||||
if (val == null || val.isEmpty) {
|
}
|
||||||
return 'Please fill in this field';
|
// FIX: tambah validasi format email
|
||||||
}
|
if (!RegExp(r'^[\w.-]+@[\w.-]+\.\w{2,}$').hasMatch(val)) {
|
||||||
return null;
|
return 'Format email tidak valid';
|
||||||
},
|
}
|
||||||
),
|
return null;
|
||||||
),
|
},
|
||||||
const SizedBox(height: 10),
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
|
||||||
// Password
|
// Password
|
||||||
SizedBox(
|
MyTextField(
|
||||||
width: MediaQuery.of(context).size.width * 0.9,
|
controller: passwordController,
|
||||||
child: MyTextField(
|
hintText: 'Minimal 6 karakter',
|
||||||
controller: passwordController,
|
obscureText: obscurePassword,
|
||||||
hintText: 'Minimal 6 karakter',
|
keyboardType: TextInputType.visiblePassword,
|
||||||
obscureText: obscurePassword,
|
prefixIcon: const Icon(CupertinoIcons.lock_fill),
|
||||||
keyboardType: TextInputType.visiblePassword,
|
suffixIcon: IconButton(
|
||||||
prefixIcon: const Icon(CupertinoIcons.lock_fill),
|
onPressed: () {
|
||||||
suffixIcon: IconButton(
|
setState(() {
|
||||||
onPressed: () {
|
obscurePassword = !obscurePassword;
|
||||||
setState(() {
|
iconPassword = obscurePassword
|
||||||
obscurePassword = !obscurePassword;
|
? CupertinoIcons.eye_fill
|
||||||
iconPassword = obscurePassword
|
: CupertinoIcons.eye_slash_fill;
|
||||||
? CupertinoIcons.eye_fill
|
});
|
||||||
: CupertinoIcons.eye_slash_fill;
|
},
|
||||||
});
|
icon: Icon(iconPassword),
|
||||||
},
|
),
|
||||||
icon: Icon(iconPassword),
|
validator: (val) {
|
||||||
|
if (val == null || val.isEmpty) {
|
||||||
|
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
|
||||||
|
MyTextField(
|
||||||
|
controller: confirmPasswordController,
|
||||||
|
hintText: 'Ulangi password',
|
||||||
|
obscureText: obscurePassword,
|
||||||
|
keyboardType: TextInputType.visiblePassword,
|
||||||
|
prefixIcon: const Icon(CupertinoIcons.lock_fill),
|
||||||
|
validator: (val) {
|
||||||
|
if (val == null || val.isEmpty) {
|
||||||
|
return 'Mohon isi field ini';
|
||||||
|
}
|
||||||
|
if (val != passwordController.text) {
|
||||||
|
return 'Password tidak cocok';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
|
// Tombol Sign Up
|
||||||
|
Center(
|
||||||
|
child: !signUpRequired
|
||||||
|
? SizedBox(
|
||||||
|
width: MediaQuery.of(context).size.width * 0.5,
|
||||||
|
child: ElevatedButton(
|
||||||
|
onPressed: () {
|
||||||
|
if (_formKey.currentState!.validate()) {
|
||||||
|
// 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: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor:
|
||||||
|
Theme.of(context).colorScheme.primary,
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(60),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: const Padding(
|
||||||
|
padding: EdgeInsets.symmetric(
|
||||||
|
horizontal: 25, vertical: 5),
|
||||||
|
child: Text(
|
||||||
|
'Sign Up',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 16, fontWeight: FontWeight.w600),
|
||||||
),
|
),
|
||||||
validator: (val) {
|
|
||||||
if (val == null || val.isEmpty) {
|
|
||||||
return 'Please fill in this field';
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 10),
|
)
|
||||||
|
: const CircularProgressIndicator(),
|
||||||
|
),
|
||||||
|
|
||||||
// Confirm Password
|
const SizedBox(height: 16),
|
||||||
SizedBox(
|
Row(
|
||||||
width: MediaQuery.of(context).size.width * 0.9,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
child: MyTextField(
|
children: [
|
||||||
controller: confirmPasswordController,
|
const Text('Sudah punya akun? '),
|
||||||
hintText: 'Ulangi password',
|
TextButton(
|
||||||
obscureText: obscurePassword,
|
onPressed: widget.onSignInTap,
|
||||||
keyboardType: TextInputType.visiblePassword,
|
child: const Text(
|
||||||
prefixIcon: const Icon(CupertinoIcons.lock_fill),
|
'Login di sini',
|
||||||
validator: (val) {
|
style: TextStyle(fontWeight: FontWeight.bold),
|
||||||
if (val == null || val.isEmpty) {
|
|
||||||
return 'Please fill in this field';
|
|
||||||
} else if (val != passwordController.text) {
|
|
||||||
return 'Password tidak cocok';
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
const SizedBox(height: 18),
|
|
||||||
|
|
||||||
/// == Sign Up == ///
|
|
||||||
// Button
|
|
||||||
!signUpRequired
|
|
||||||
? Center(
|
|
||||||
child: 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,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
style: TextButton.styleFrom(
|
|
||||||
backgroundColor:
|
|
||||||
Theme.of(context).colorScheme.primary,
|
|
||||||
foregroundColor: Colors.white,
|
|
||||||
shape: RoundedRectangleBorder(
|
|
||||||
borderRadius: BorderRadius.circular(60),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
child: Padding(
|
|
||||||
padding: EdgeInsets.symmetric(
|
|
||||||
horizontal: 25, vertical: 5),
|
|
||||||
child: const Text(
|
|
||||||
'Sign Up',
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 16,
|
|
||||||
fontWeight: FontWeight.w600),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
: const CircularProgressIndicator(),
|
|
||||||
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
Center(
|
|
||||||
child: Text('Atau'),
|
|
||||||
),
|
|
||||||
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
Center(
|
|
||||||
child: Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
const Text('Sudah punya akun? '),
|
|
||||||
TextButton(
|
|
||||||
onPressed: () {
|
|
||||||
widget.onSignInTap?.call();
|
|
||||||
},
|
|
||||||
child: const Text(
|
|
||||||
'Login di sini',
|
|
||||||
style: TextStyle(fontWeight: FontWeight.bold),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 30),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
const SizedBox(height: 16),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.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_in_bloc/sign_in_bloc.dart';
|
||||||
import '../blocs/sign_up_bloc/sign_up_bloc.dart';
|
import '../blocs/sign_up_bloc/sign_up_bloc.dart';
|
||||||
import 'sign_in_screen.dart';
|
import 'sign_in_screen.dart';
|
||||||
|
|
@ -19,84 +20,66 @@ class _WelcomeScreenState extends State<WelcomeScreen>
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
tabController = TabController(initialIndex: 0, length: 2, vsync: this);
|
|
||||||
super.initState();
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: Colors.white,
|
backgroundColor: Colors.white,
|
||||||
body: SingleChildScrollView(
|
body: SafeArea(
|
||||||
child: SizedBox(
|
child: Column(
|
||||||
height: MediaQuery.of(context).size.height,
|
children: [
|
||||||
child: Stack(
|
const SizedBox(height: 24),
|
||||||
children: [
|
Padding(
|
||||||
Align(
|
padding: const EdgeInsets.symmetric(horizontal: 50.0),
|
||||||
alignment: Alignment.center,
|
child: TabBar(
|
||||||
child: SizedBox(
|
controller: tabController,
|
||||||
height: MediaQuery.of(context).size.height / 1.8,
|
unselectedLabelColor:
|
||||||
child: Column(
|
Theme.of(context).colorScheme.onSurface.withOpacity(0.5),
|
||||||
children: [
|
labelColor: Theme.of(context).colorScheme.onSurface,
|
||||||
Padding(
|
tabs: const [
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 50.0),
|
Padding(
|
||||||
child: TabBar(
|
padding: EdgeInsets.all(12.0),
|
||||||
controller: tabController,
|
child: Text('Sign In', style: TextStyle(fontSize: 18)),
|
||||||
unselectedLabelColor: Theme.of(context)
|
|
||||||
.colorScheme
|
|
||||||
.onBackground
|
|
||||||
.withOpacity(0.5),
|
|
||||||
labelColor:
|
|
||||||
Theme.of(context).colorScheme.onBackground,
|
|
||||||
tabs: const [
|
|
||||||
Padding(
|
|
||||||
padding: EdgeInsets.all(12.0),
|
|
||||||
child: Text(
|
|
||||||
'Sign In',
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 18,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Padding(
|
|
||||||
padding: EdgeInsets.all(12.0),
|
|
||||||
child: Text(
|
|
||||||
'Sign Up',
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 18,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Expanded(
|
|
||||||
child: TabBarView(
|
|
||||||
controller: tabController,
|
|
||||||
children: [
|
|
||||||
BlocProvider<SignInBloc>(
|
|
||||||
create: (context) => SignInBloc(context
|
|
||||||
.read<AuthenticationBloc>()
|
|
||||||
.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
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
))
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
Padding(
|
||||||
)
|
padding: EdgeInsets.all(12.0),
|
||||||
],
|
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) =>
|
||||||
|
// FIX: akses UserRepository langsung, bukan via AuthBloc
|
||||||
|
SignInBloc(context.read<UserRepository>()),
|
||||||
|
child: const SignInScreen(),
|
||||||
|
),
|
||||||
|
BlocProvider<SignUpBloc>(
|
||||||
|
create: (context) =>
|
||||||
|
SignUpBloc(context.read<UserRepository>()),
|
||||||
|
child: SignUpScreen(
|
||||||
|
onSignInTap: () => tabController.animateTo(0),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import '../../monitoring/evaporasi/blocs/evaporasi_bloc.dart';
|
||||||
import '../../monitoring/atmospheric_conditions/blocs/atmospheric_conditions_bloc.dart';
|
import '../../monitoring/atmospheric_conditions/blocs/atmospheric_conditions_bloc.dart';
|
||||||
import '../widgets/notification_panel.dart';
|
import '../widgets/notification_panel.dart';
|
||||||
import '../widgets/sensor_grid.dart';
|
import '../widgets/sensor_grid.dart';
|
||||||
|
import '../widgets/dashboard_charts.dart';
|
||||||
import 'main_drawer.dart';
|
import 'main_drawer.dart';
|
||||||
|
|
||||||
class HomeScreen extends StatefulWidget {
|
class HomeScreen extends StatefulWidget {
|
||||||
|
|
@ -199,6 +200,9 @@ class _HomeBody extends StatelessWidget {
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
const SensorGrid(),
|
const SensorGrid(),
|
||||||
|
const SizedBox(height: 24), // ← TAMBAH
|
||||||
|
const DashboardCharts(), // ← TAMBAH
|
||||||
|
const SizedBox(height: 16),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -23,26 +23,29 @@ class EvaporasiBloc extends Bloc<EvaporasiEvent, EvaporasiState> {
|
||||||
on<WatchEvaporasiStarted>(_onStarted);
|
on<WatchEvaporasiStarted>(_onStarted);
|
||||||
on<_EvaporasiRealtimeUpdated>(_onRealtimeUpdated);
|
on<_EvaporasiRealtimeUpdated>(_onRealtimeUpdated);
|
||||||
on<EvaporasiPeriodChanged>(_onPeriodChanged);
|
on<EvaporasiPeriodChanged>(_onPeriodChanged);
|
||||||
|
on<EvaporasiDateSelected>(_onDateSelected);
|
||||||
|
on<EvaporasiViewModeChanged>(_onViewModeChanged);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// =========================
|
|
||||||
/// 🚀 START
|
|
||||||
/// =========================
|
|
||||||
Future<void> _onStarted(
|
Future<void> _onStarted(
|
||||||
WatchEvaporasiStarted event,
|
WatchEvaporasiStarted event,
|
||||||
Emitter<EvaporasiState> emit,
|
Emitter<EvaporasiState> emit,
|
||||||
) async {
|
) async {
|
||||||
emit(state.copyWith(isLoading: true));
|
emit(state.copyWith(isLoading: true));
|
||||||
|
|
||||||
|
// Ambil history yang akan dipakai untuk list + agregasi grafik.
|
||||||
final history = await _repository.getSensorHistory(
|
final history = await _repository.getSensorHistory(
|
||||||
'evaporasi/history',
|
'Monitoring/History',
|
||||||
(json) => Evaporasi.fromJson(json),
|
(json) => Evaporasi.fromJson(json),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
final listData = List<Evaporasi>.from(history)
|
||||||
|
..sort((a, b) => a.timestamp.compareTo(b.timestamp));
|
||||||
|
|
||||||
final dailyGraph = TimeSeriesMapper.toDaily(
|
final dailyGraph = TimeSeriesMapper.toDaily(
|
||||||
data: history,
|
data: history,
|
||||||
getTime: (e) => e.timestamp,
|
getTime: (e) => e.timestamp,
|
||||||
getValue: (e) => e.evaporasi,
|
getValue: (e) => e.evaporasi, // ⚠️ sesuaikan nama field
|
||||||
);
|
);
|
||||||
|
|
||||||
final dailyTempGraph = TimeSeriesMapper.toDaily(
|
final dailyTempGraph = TimeSeriesMapper.toDaily(
|
||||||
|
|
@ -51,17 +54,51 @@ class EvaporasiBloc extends Bloc<EvaporasiEvent, EvaporasiState> {
|
||||||
getValue: (e) => e.suhu,
|
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 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);
|
final (status, rain) = _computeWeatherStatus(lastValue);
|
||||||
_emitEvaporasiAlert(status, rain, lastValue);
|
_emitEvaporasiAlert(status, rain, lastValue);
|
||||||
|
|
||||||
emit(state.copyWith(
|
emit(state.copyWith(
|
||||||
history: history,
|
history: history,
|
||||||
|
currentValue: lastValue,
|
||||||
|
waterLevel: lastWaterLevel,
|
||||||
|
temperature: lastTemperature,
|
||||||
dailyValues: dailyGraph,
|
dailyValues: dailyGraph,
|
||||||
dailyTemperatures: dailyTempGraph,
|
dailyTemperatures: dailyTempGraph,
|
||||||
|
weeklyValues: weeklyGraph,
|
||||||
|
monthlyValues: monthlyGraph,
|
||||||
|
weeklyTemperatures: weeklyTemp,
|
||||||
|
monthlyTemperatures: monthlyTemp,
|
||||||
|
chartLabels: _buildChartLabels(period: 'Hari Ini'),
|
||||||
weatherStatus: status,
|
weatherStatus: status,
|
||||||
willRain: rain,
|
willRain: rain,
|
||||||
|
listData: listData,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
));
|
));
|
||||||
|
|
||||||
|
|
@ -71,20 +108,35 @@ class EvaporasiBloc extends Bloc<EvaporasiEvent, EvaporasiState> {
|
||||||
.listen((data) => add(_EvaporasiRealtimeUpdated(data)));
|
.listen((data) => add(_EvaporasiRealtimeUpdated(data)));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// =========================
|
|
||||||
/// ⚡ REALTIME
|
|
||||||
/// =========================
|
|
||||||
void _onRealtimeUpdated(
|
void _onRealtimeUpdated(
|
||||||
_EvaporasiRealtimeUpdated event,
|
_EvaporasiRealtimeUpdated event,
|
||||||
Emitter<EvaporasiState> emit,
|
Emitter<EvaporasiState> emit,
|
||||||
) {
|
) {
|
||||||
final updated = List<double>.from(state.dailyValues);
|
// Hindari update dobel: jika timestamp event sama dengan yang terakhir, jangan ubah bucket.
|
||||||
final updatedTemp = List<double>.from(state.dailyTemperatures);
|
final previous = state.history.isNotEmpty ? state.history.last.timestamp : null;
|
||||||
final index = DateTime.now().hour;
|
final isDuplicate =
|
||||||
|
previous != null && event.data.timestamp.toUtc() == previous.toUtc();
|
||||||
|
|
||||||
if (index < updated.length) {
|
// Update bucket berdasarkan timestamp event (bukan jam lokal sekarang).
|
||||||
updated[index] = event.data.evaporasi;
|
final updated = isDuplicate ? state.dailyValues : List<double>.from(state.dailyValues);
|
||||||
updatedTemp[index] = event.data.suhu;
|
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);
|
final (status, rain) = _computeWeatherStatus(event.data.evaporasi);
|
||||||
|
|
@ -101,20 +153,18 @@ class EvaporasiBloc extends Bloc<EvaporasiEvent, EvaporasiState> {
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// =========================
|
|
||||||
/// 📊 PERIOD
|
|
||||||
/// =========================
|
|
||||||
Future<void> _onPeriodChanged(
|
Future<void> _onPeriodChanged(
|
||||||
EvaporasiPeriodChanged event,
|
EvaporasiPeriodChanged event,
|
||||||
Emitter<EvaporasiState> emit,
|
Emitter<EvaporasiState> emit,
|
||||||
) async {
|
) async {
|
||||||
emit(state.copyWith(isLoading: true, selectedPeriod: event.period));
|
emit(state.copyWith(isLoading: true, selectedPeriod: event.period));
|
||||||
|
|
||||||
final history = state.history;
|
final history = state.history;
|
||||||
|
|
||||||
List<double> updated;
|
List<double> updated;
|
||||||
List<double> updatedTemp;
|
List<double> updatedTemp;
|
||||||
|
|
||||||
if (event.period == "Minggu Ini") {
|
if (event.period == 'Minggu Ini') {
|
||||||
updated = TimeSeriesMapper.toWeekly(
|
updated = TimeSeriesMapper.toWeekly(
|
||||||
data: history,
|
data: history,
|
||||||
getTime: (e) => e.timestamp,
|
getTime: (e) => e.timestamp,
|
||||||
|
|
@ -125,7 +175,7 @@ class EvaporasiBloc extends Bloc<EvaporasiEvent, EvaporasiState> {
|
||||||
getTime: (e) => e.timestamp,
|
getTime: (e) => e.timestamp,
|
||||||
getValue: (e) => e.suhu,
|
getValue: (e) => e.suhu,
|
||||||
);
|
);
|
||||||
} else if (event.period == "Bulan Ini") {
|
} else if (event.period == 'Bulan Ini') {
|
||||||
updated = TimeSeriesMapper.toMonthly(
|
updated = TimeSeriesMapper.toMonthly(
|
||||||
data: history,
|
data: history,
|
||||||
getTime: (e) => e.timestamp,
|
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(
|
emit(state.copyWith(
|
||||||
dailyValues: updated,
|
dailyValues: updated,
|
||||||
dailyTemperatures: updatedTemp,
|
dailyTemperatures: updatedTemp,
|
||||||
isLoading: false,
|
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
|
@override
|
||||||
Future<void> close() async {
|
Future<void> close() async {
|
||||||
await _subscription?.cancel();
|
await _subscription?.cancel();
|
||||||
return super.close();
|
return super.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// =========================
|
|
||||||
/// 🌤️ HELPER: Status Cuaca
|
|
||||||
/// =========================
|
|
||||||
static (String status, bool willRain) _computeWeatherStatus(double value) {
|
static (String status, bool willRain) _computeWeatherStatus(double value) {
|
||||||
if (value <= 5.0) return ('Baik', false);
|
if (value <= 5.0) return ('Baik', false);
|
||||||
if (value <= 10.0) return ('Sedang', true);
|
if (value <= 10.0) return ('Sedang', true);
|
||||||
|
|
@ -185,7 +282,7 @@ class EvaporasiBloc extends Bloc<EvaporasiEvent, EvaporasiState> {
|
||||||
message =
|
message =
|
||||||
'Evaporasi ${value.toStringAsFixed(1)} mm — status sedang, potensi hujan';
|
'Evaporasi ${value.toStringAsFixed(1)} mm — status sedang, potensi hujan';
|
||||||
} else {
|
} else {
|
||||||
severity = AlertSeverity.info; // normal → bersihkan alert
|
severity = AlertSeverity.info;
|
||||||
message = '';
|
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 {
|
class _EvaporasiRealtimeUpdated extends EvaporasiEvent {
|
||||||
final Evaporasi data;
|
final Evaporasi data;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -19,3 +19,23 @@ class EvaporasiPeriodChanged extends EvaporasiEvent {
|
||||||
@override
|
@override
|
||||||
List<Object> get props => [period];
|
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];
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,35 @@
|
||||||
part of 'evaporasi_bloc.dart';
|
part of 'evaporasi_bloc.dart';
|
||||||
|
|
||||||
|
enum EvaporasiViewMode { period, customDate }
|
||||||
|
|
||||||
class EvaporasiState extends Equatable {
|
class EvaporasiState extends Equatable {
|
||||||
final double currentValue; // nilai evaporasi realtime
|
final double currentValue; // nilai evaporasi realtime
|
||||||
final double temperature; // suhu (opsional dari firebase)
|
final double temperature; // suhu (opsional dari firebase)
|
||||||
final double waterLevel; // tinggi air
|
final double waterLevel; // tinggi air
|
||||||
final String selectedPeriod;
|
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<double> dailyTemperatures; // untuk grafik suhu
|
||||||
final List<Evaporasi> history;
|
final List<double> weeklyTemperatures;
|
||||||
|
final List<double> monthlyTemperatures;
|
||||||
|
|
||||||
final String weatherStatus; // Baik / Sedang / Buruk
|
/// Label X-axis sesuai agregasi period
|
||||||
final bool willRain; // true jika status Sedang/Buruk
|
/// 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;
|
final bool isLoading;
|
||||||
|
|
||||||
|
|
@ -19,11 +37,19 @@ class EvaporasiState extends Equatable {
|
||||||
this.currentValue = 0.0,
|
this.currentValue = 0.0,
|
||||||
this.temperature = 0.0,
|
this.temperature = 0.0,
|
||||||
this.waterLevel = 0.0,
|
this.waterLevel = 0.0,
|
||||||
this.selectedPeriod = "Hari Ini",
|
this.selectedPeriod = 'Hari Ini',
|
||||||
|
this.selectedDate,
|
||||||
|
this.viewMode = EvaporasiViewMode.period,
|
||||||
this.dailyValues = const [],
|
this.dailyValues = const [],
|
||||||
|
this.weeklyValues = const [],
|
||||||
|
this.monthlyValues = const [],
|
||||||
this.dailyTemperatures = const [],
|
this.dailyTemperatures = const [],
|
||||||
|
this.weeklyTemperatures = const [],
|
||||||
|
this.monthlyTemperatures = const [],
|
||||||
|
this.chartLabels = const [],
|
||||||
|
this.listData = const [],
|
||||||
this.history = const [],
|
this.history = const [],
|
||||||
this.weatherStatus = "Baik",
|
this.weatherStatus = 'Baik',
|
||||||
this.willRain = false,
|
this.willRain = false,
|
||||||
this.isLoading = true,
|
this.isLoading = true,
|
||||||
});
|
});
|
||||||
|
|
@ -33,8 +59,16 @@ class EvaporasiState extends Equatable {
|
||||||
double? temperature,
|
double? temperature,
|
||||||
double? waterLevel,
|
double? waterLevel,
|
||||||
String? selectedPeriod,
|
String? selectedPeriod,
|
||||||
|
DateTime? selectedDate,
|
||||||
|
EvaporasiViewMode? viewMode,
|
||||||
List<double>? dailyValues,
|
List<double>? dailyValues,
|
||||||
|
List<double>? weeklyValues,
|
||||||
|
List<double>? monthlyValues,
|
||||||
List<double>? dailyTemperatures,
|
List<double>? dailyTemperatures,
|
||||||
|
List<double>? weeklyTemperatures,
|
||||||
|
List<double>? monthlyTemperatures,
|
||||||
|
List<String>? chartLabels,
|
||||||
|
List<Evaporasi>? listData,
|
||||||
List<Evaporasi>? history,
|
List<Evaporasi>? history,
|
||||||
String? weatherStatus,
|
String? weatherStatus,
|
||||||
bool? willRain,
|
bool? willRain,
|
||||||
|
|
@ -45,8 +79,16 @@ class EvaporasiState extends Equatable {
|
||||||
temperature: temperature ?? this.temperature,
|
temperature: temperature ?? this.temperature,
|
||||||
waterLevel: waterLevel ?? this.waterLevel,
|
waterLevel: waterLevel ?? this.waterLevel,
|
||||||
selectedPeriod: selectedPeriod ?? this.selectedPeriod,
|
selectedPeriod: selectedPeriod ?? this.selectedPeriod,
|
||||||
|
selectedDate: selectedDate ?? this.selectedDate,
|
||||||
|
viewMode: viewMode ?? this.viewMode,
|
||||||
dailyValues: dailyValues ?? this.dailyValues,
|
dailyValues: dailyValues ?? this.dailyValues,
|
||||||
|
weeklyValues: weeklyValues ?? this.weeklyValues,
|
||||||
|
monthlyValues: monthlyValues ?? this.monthlyValues,
|
||||||
dailyTemperatures: dailyTemperatures ?? this.dailyTemperatures,
|
dailyTemperatures: dailyTemperatures ?? this.dailyTemperatures,
|
||||||
|
weeklyTemperatures: weeklyTemperatures ?? this.weeklyTemperatures,
|
||||||
|
monthlyTemperatures: monthlyTemperatures ?? this.monthlyTemperatures,
|
||||||
|
chartLabels: chartLabels ?? this.chartLabels,
|
||||||
|
listData: listData ?? this.listData,
|
||||||
history: history ?? this.history,
|
history: history ?? this.history,
|
||||||
weatherStatus: weatherStatus ?? this.weatherStatus,
|
weatherStatus: weatherStatus ?? this.weatherStatus,
|
||||||
willRain: willRain ?? this.willRain,
|
willRain: willRain ?? this.willRain,
|
||||||
|
|
@ -55,16 +97,25 @@ class EvaporasiState extends Equatable {
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
List<Object> get props => [
|
List<Object?> get props => [
|
||||||
currentValue,
|
currentValue,
|
||||||
temperature,
|
temperature,
|
||||||
waterLevel,
|
waterLevel,
|
||||||
selectedPeriod,
|
selectedPeriod,
|
||||||
|
selectedDate,
|
||||||
|
viewMode,
|
||||||
dailyValues,
|
dailyValues,
|
||||||
|
weeklyValues,
|
||||||
|
monthlyValues,
|
||||||
dailyTemperatures,
|
dailyTemperatures,
|
||||||
|
weeklyTemperatures,
|
||||||
|
monthlyTemperatures,
|
||||||
|
chartLabels,
|
||||||
|
listData,
|
||||||
history,
|
history,
|
||||||
weatherStatus,
|
weatherStatus,
|
||||||
willRain,
|
willRain,
|
||||||
isLoading,
|
isLoading,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,21 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
|
|
||||||
import '../blocs/evaporasi_bloc.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/utils/pdf/pdf_export_service.dart';
|
||||||
import '../../shared/widgets/export_pdf_button.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});
|
const EvaporasiScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<EvaporasiScreen> createState() => _EvaporasiScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _EvaporasiScreenState extends State<EvaporasiScreen> {
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
|
|
@ -48,18 +55,55 @@ class EvaporasiScreen extends StatelessWidget {
|
||||||
const SizedBox(height: 25),
|
const SizedBox(height: 25),
|
||||||
_statusCard(state),
|
_statusCard(state),
|
||||||
const SizedBox(height: 25),
|
const SizedBox(height: 25),
|
||||||
const Text("Tren Evaporasi & Suhu",
|
const Text(
|
||||||
style:
|
"Tren Evaporasi & Suhu",
|
||||||
TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
|
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
||||||
const SizedBox(height: 15),
|
|
||||||
const EvaporasiPeriodSelector(),
|
|
||||||
const SizedBox(height: 15),
|
|
||||||
EvaporasiChartWidget(
|
|
||||||
dailyValues: state.dailyValues,
|
|
||||||
dailyTemperatures: state.dailyTemperatures,
|
|
||||||
period: state.selectedPeriod,
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 25),
|
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),
|
||||||
|
// Chart
|
||||||
|
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,
|
||||||
|
chartLabels: state.chartLabels,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
// List data (mengikuti chart: period vs custom date)
|
||||||
|
_evaporasiList(state),
|
||||||
|
const SizedBox(height: 10),
|
||||||
ExportPdfButton(
|
ExportPdfButton(
|
||||||
onExport: () => PdfExportService.evaporasi(
|
onExport: () => PdfExportService.evaporasi(
|
||||||
evaporasi: state.currentValue,
|
evaporasi: state.currentValue,
|
||||||
|
|
@ -160,7 +204,7 @@ class EvaporasiScreen extends StatelessWidget {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// =========================
|
/// =========================
|
||||||
/// 🌤️ STATUS CARD
|
/// 📈 STATUS CARD
|
||||||
/// =========================
|
/// =========================
|
||||||
Widget _statusCard(EvaporasiState state) {
|
Widget _statusCard(EvaporasiState state) {
|
||||||
Color statusColor;
|
Color statusColor;
|
||||||
|
|
@ -182,13 +226,23 @@ class EvaporasiScreen extends StatelessWidget {
|
||||||
break;
|
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(
|
return Container(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
padding: const EdgeInsets.all(20),
|
padding: const EdgeInsets.all(16),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
borderRadius: BorderRadius.circular(20),
|
borderRadius: BorderRadius.circular(20),
|
||||||
border: Border.all(color: statusColor.withOpacity(0.3), width: 1.5),
|
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
|
|
@ -204,7 +258,11 @@ class EvaporasiScreen extends StatelessWidget {
|
||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Text(
|
Text(
|
||||||
state.weatherStatus,
|
state.weatherStatus == 'Baik'
|
||||||
|
? 'Normal'
|
||||||
|
: state.weatherStatus == 'Sedang'
|
||||||
|
? 'Sedang'
|
||||||
|
: 'Tinggi',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 20,
|
fontSize: 20,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
|
|
@ -212,9 +270,9 @@ class EvaporasiScreen extends StatelessWidget {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (state.willRain)
|
if (state.willRain)
|
||||||
const Text(
|
Text(
|
||||||
"⚠️ Potensi hujan tinggi",
|
warningText,
|
||||||
style: TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
color: Colors.red,
|
color: Colors.red,
|
||||||
fontWeight: FontWeight.w500,
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,51 +1,294 @@
|
||||||
import 'package:fl_chart/fl_chart.dart';
|
import 'package:fl_chart/fl_chart.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
const _tooltipBgColor = Colors.black87;
|
||||||
|
|
||||||
|
|
||||||
class EvaporasiChartWidget extends StatelessWidget {
|
class EvaporasiChartWidget extends StatelessWidget {
|
||||||
|
|
||||||
final List<double> dailyValues;
|
final List<double> dailyValues;
|
||||||
final List<double> dailyTemperatures;
|
final List<double> dailyTemperatures;
|
||||||
final String period;
|
final String period;
|
||||||
|
final List<String> chartLabels;
|
||||||
|
|
||||||
const EvaporasiChartWidget({
|
const EvaporasiChartWidget({
|
||||||
super.key,
|
super.key,
|
||||||
required this.dailyValues,
|
required this.dailyValues,
|
||||||
required this.dailyTemperatures,
|
required this.dailyTemperatures,
|
||||||
required this.period,
|
required this.period,
|
||||||
|
required this.chartLabels,
|
||||||
});
|
});
|
||||||
|
|
||||||
double _getMaxY(List<double> data) {
|
double _safeValue(double value) {
|
||||||
if (data.isEmpty) return 10;
|
if (value.isNaN || value.isInfinite) return 0.0;
|
||||||
final max = data.reduce((a, b) => a > b ? a : b);
|
return value < 0 ? 0.0 : value;
|
||||||
return (max + 5).clamp(10, 100);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
List<double> _safeData(List<double> data) {
|
double _minOf(List<double> values) {
|
||||||
return data.map((e) {
|
if (values.isEmpty) return 0.0;
|
||||||
if (e.isNaN || e.isInfinite) return 0.0;
|
return values.map(_safeValue).reduce((a, b) => a < b ? a : b);
|
||||||
return e < 0 ? 0.0 : e;
|
}
|
||||||
|
|
||||||
|
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();
|
}).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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final safeValues = _safeData(dailyValues);
|
if (dailyValues.isEmpty && dailyTemperatures.isEmpty) {
|
||||||
final safeTemps = _safeData(dailyTemperatures);
|
return Container(
|
||||||
|
height: 240,
|
||||||
|
alignment: Alignment.center,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(25),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withAlpha(13),
|
||||||
|
blurRadius: 10,
|
||||||
|
offset: const Offset(0, 5),
|
||||||
|
)
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: const Text('Tidak ada data chart'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Gunakan max dari kedua dataset agar skala Y sesuai
|
// Hitung range masing-masing agar axis kanan (°C) masuk akal.
|
||||||
final maxY = _getMaxY([
|
final evapMinRaw = _minOf(dailyValues);
|
||||||
...safeValues,
|
final evapMaxRaw = _maxOf(dailyValues);
|
||||||
...safeTemps,
|
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(
|
return Container(
|
||||||
height: 280,
|
height: 340,
|
||||||
padding: const EdgeInsets.fromLTRB(10, 20, 20, 10),
|
padding: const EdgeInsets.all(12),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
borderRadius: BorderRadius.circular(25),
|
borderRadius: BorderRadius.circular(25),
|
||||||
boxShadow: [
|
boxShadow: [
|
||||||
BoxShadow(
|
BoxShadow(
|
||||||
color: Colors.black.withOpacity(0.05),
|
color: Colors.black.withAlpha(13),
|
||||||
blurRadius: 10,
|
blurRadius: 10,
|
||||||
offset: const Offset(0, 5),
|
offset: const Offset(0, 5),
|
||||||
)
|
)
|
||||||
|
|
@ -55,196 +298,34 @@ class EvaporasiChartWidget extends StatelessWidget {
|
||||||
children: [
|
children: [
|
||||||
// Legend
|
// Legend
|
||||||
Row(
|
Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||||
children: [
|
children: [
|
||||||
_legendItem(Colors.blue.shade700, "Evaporasi (mm)"),
|
Row(
|
||||||
const SizedBox(width: 20),
|
children: [
|
||||||
_legendItem(Colors.orange.shade600, "Suhu (°C)"),
|
Container(width: 10, height: 10, decoration: BoxDecoration(color: Colors.blue.shade700, shape: BoxShape.circle)),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
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: 10),
|
const SizedBox(height: 8),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: LineChart(
|
child: ClipRRect(
|
||||||
duration: const Duration(milliseconds: 600),
|
borderRadius: BorderRadius.circular(25),
|
||||||
curve: Curves.easeOutCubic,
|
child: chart,
|
||||||
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(
|
|
||||||
children: [
|
|
||||||
Container(
|
|
||||||
width: 12,
|
|
||||||
height: 12,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: color,
|
|
||||||
borderRadius: BorderRadius.circular(3),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 6),
|
|
||||||
Text(
|
|
||||||
label,
|
|
||||||
style: const TextStyle(fontSize: 12, color: Colors.grey),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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(() {});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,14 +1,17 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import '../../blocs/evaporasi_bloc.dart';
|
import '../../blocs/evaporasi_bloc.dart';
|
||||||
|
import 'evaporasi_date_picker.dart';
|
||||||
|
|
||||||
class EvaporasiPeriodSelector extends StatelessWidget {
|
class EvaporasiPeriodSelector extends StatelessWidget {
|
||||||
const EvaporasiPeriodSelector({super.key});
|
const EvaporasiPeriodSelector({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final selectedPeriod =
|
final state = context.select((EvaporasiBloc bloc) => bloc.state);
|
||||||
context.select((EvaporasiBloc bloc) => bloc.state.selectedPeriod);
|
final selectedPeriod = state.selectedPeriod;
|
||||||
|
final viewMode = state.viewMode;
|
||||||
|
final selectedDate = state.selectedDate;
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
padding: const EdgeInsets.all(4),
|
padding: const EdgeInsets.all(4),
|
||||||
|
|
@ -19,16 +22,20 @@ class EvaporasiPeriodSelector extends StatelessWidget {
|
||||||
child: Row(
|
child: Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
_buildTab(context, "Hari Ini", selectedPeriod),
|
_buildTab(context, "Hari Ini", selectedPeriod, viewMode),
|
||||||
_buildTab(context, "Minggu Ini", selectedPeriod),
|
_buildTab(context, "Minggu Ini", selectedPeriod, viewMode),
|
||||||
_buildTab(context, "Bulan Ini", selectedPeriod),
|
_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) {
|
Widget _buildTab(BuildContext context, String label, String current, EvaporasiViewMode viewMode) {
|
||||||
bool isActive = label == current;
|
// If in customDate mode, show period tabs as inactive
|
||||||
|
bool isActive = viewMode == EvaporasiViewMode.period && label == current;
|
||||||
|
|
||||||
return GestureDetector(
|
return GestureDetector(
|
||||||
onTap: () {
|
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}";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -19,26 +19,115 @@ class Evaporasi {
|
||||||
);
|
);
|
||||||
|
|
||||||
factory Evaporasi.fromJson(Map<dynamic, dynamic> json) {
|
factory Evaporasi.fromJson(Map<dynamic, dynamic> json) {
|
||||||
final now = DateTime.now();
|
double toDoubleSafe(dynamic v) {
|
||||||
DateTime timestamp = now;
|
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 waktuStr = json['waktu'] as String?;
|
final evaporasiVal = toDoubleSafe(
|
||||||
if (waktuStr != null) {
|
json['evaporasi_mm'] ??
|
||||||
final parts = waktuStr.split(':');
|
json['evaporasi'] ??
|
||||||
if (parts.length >= 2) {
|
json['evaporasiMm'] ??
|
||||||
final jam = int.tryParse(parts[0]) ?? 0;
|
json['evaporation_mm'] ??
|
||||||
final menit = int.tryParse(parts[1]) ?? 0;
|
json['evap_mm'] ??
|
||||||
final detik = parts.length >= 3 ? (int.tryParse(parts[2]) ?? 0) : 0;
|
json['evaporasi_mm_'] ??
|
||||||
timestamp = DateTime(now.year, now.month, now.day, jam, menit, detik);
|
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 && 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(
|
return Evaporasi(
|
||||||
evaporasi: (json['evaporasi'] ?? 0).toDouble(),
|
evaporasi: evaporasiVal,
|
||||||
suhu: (json['suhu_air'] ?? 0).toDouble(),
|
suhu: suhuVal,
|
||||||
tinggiAir: (json['tinggi_air'] ?? 0).toDouble(),
|
tinggiAir: tinggiVal,
|
||||||
|
|
||||||
/// 🔥 bikin timestamp dari jam & menit
|
|
||||||
timestamp: timestamp,
|
timestamp: timestamp,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,11 +10,9 @@ class FirebaseUserRepo implements UserRepository {
|
||||||
final FirebaseAuth _firebaseAuth;
|
final FirebaseAuth _firebaseAuth;
|
||||||
final userCollection = FirebaseFirestore.instance.collection('users');
|
final userCollection = FirebaseFirestore.instance.collection('users');
|
||||||
|
|
||||||
FirebaseUserRepo({
|
FirebaseUserRepo({FirebaseAuth? firebaseAuth})
|
||||||
FirebaseAuth? firebaseAuth,
|
: _firebaseAuth = firebaseAuth ?? FirebaseAuth.instance;
|
||||||
}) : _firebaseAuth = firebaseAuth ?? FirebaseAuth.instance;
|
|
||||||
|
|
||||||
/// == Melakukan Implement User == ///
|
|
||||||
@override
|
@override
|
||||||
Stream<MyUser?> get user {
|
Stream<MyUser?> get user {
|
||||||
return _firebaseAuth.authStateChanges().flatMap((firebaseUser) async* {
|
return _firebaseAuth.authStateChanges().flatMap((firebaseUser) async* {
|
||||||
|
|
@ -27,15 +25,8 @@ class FirebaseUserRepo implements UserRepository {
|
||||||
yield MyUser.fromEntity(
|
yield MyUser.fromEntity(
|
||||||
MyUserEntity.fromDocument(userData.data()!));
|
MyUserEntity.fromDocument(userData.data()!));
|
||||||
} else {
|
} else {
|
||||||
// User baru yang belum ada data di Firestore, tunggu sebentar
|
// Data Firestore belum ada (seharusnya tidak terjadi dengan flow yang sudah diperbaiki)
|
||||||
await Future.delayed(const Duration(milliseconds: 500));
|
yield MyUser.empty;
|
||||||
final retryData = await userCollection.doc(firebaseUser.uid).get();
|
|
||||||
if (retryData.exists && retryData.data() != null) {
|
|
||||||
yield MyUser.fromEntity(
|
|
||||||
MyUserEntity.fromDocument(retryData.data()!));
|
|
||||||
} else {
|
|
||||||
yield MyUser.empty;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
log('Error fetching user data: $e');
|
log('Error fetching user data: $e');
|
||||||
|
|
@ -45,7 +36,6 @@ class FirebaseUserRepo implements UserRepository {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// == Melakukan Implement Sign in == ///
|
|
||||||
@override
|
@override
|
||||||
Future<void> signIn(String email, String password) async {
|
Future<void> signIn(String email, String password) async {
|
||||||
try {
|
try {
|
||||||
|
|
@ -57,7 +47,6 @@ class FirebaseUserRepo implements UserRepository {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// == Melakukan Implement Sign Up == ///
|
|
||||||
@override
|
@override
|
||||||
Future<MyUser> signUp(MyUser myUser, String password) async {
|
Future<MyUser> signUp(MyUser myUser, String password) async {
|
||||||
try {
|
try {
|
||||||
|
|
@ -71,7 +60,6 @@ class FirebaseUserRepo implements UserRepository {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// == Melakukan Implement Log Out == ///
|
|
||||||
@override
|
@override
|
||||||
Future<void> logOut() async {
|
Future<void> logOut() async {
|
||||||
final GoogleSignIn googleSignIn = GoogleSignIn();
|
final GoogleSignIn googleSignIn = GoogleSignIn();
|
||||||
|
|
@ -85,27 +73,26 @@ class FirebaseUserRepo implements UserRepository {
|
||||||
await userCollection
|
await userCollection
|
||||||
.doc(myUser.userId)
|
.doc(myUser.userId)
|
||||||
.set(myUser.toEntity().toDocument());
|
.set(myUser.toEntity().toDocument());
|
||||||
// Tunggu sebentar agar data tersimpan dengan baik sebelum stream update
|
|
||||||
await Future.delayed(const Duration(milliseconds: 500));
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
log(e.toString());
|
log(e.toString());
|
||||||
rethrow;
|
rethrow;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// == Sign in with Google == ///
|
|
||||||
@override
|
@override
|
||||||
Future<void> signInWithGoogle() async {
|
Future<void> signInWithGoogle() async {
|
||||||
try {
|
try {
|
||||||
|
UserCredential userCredential;
|
||||||
|
|
||||||
if (kIsWeb) {
|
if (kIsWeb) {
|
||||||
final googleProvider = GoogleAuthProvider();
|
final googleProvider = GoogleAuthProvider();
|
||||||
await _firebaseAuth.signInWithPopup(googleProvider);
|
userCredential = await _firebaseAuth.signInWithPopup(googleProvider);
|
||||||
} else {
|
} else {
|
||||||
final GoogleSignIn googleSignIn = GoogleSignIn();
|
final GoogleSignIn googleSignIn = GoogleSignIn();
|
||||||
final googleUser = await googleSignIn.signIn();
|
final googleUser = await googleSignIn.signIn();
|
||||||
|
|
||||||
if (googleUser == null) {
|
if (googleUser == null) {
|
||||||
throw Exception("cancelled");
|
throw Exception('cancelled');
|
||||||
}
|
}
|
||||||
|
|
||||||
final GoogleSignInAuthentication googleAuth =
|
final GoogleSignInAuthentication googleAuth =
|
||||||
|
|
@ -115,7 +102,22 @@ class FirebaseUserRepo implements UserRepository {
|
||||||
accessToken: googleAuth.accessToken,
|
accessToken: googleAuth.accessToken,
|
||||||
idToken: googleAuth.idToken,
|
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) {
|
} catch (e) {
|
||||||
log('Google sign-in error: $e');
|
log('Google sign-in error: $e');
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
|
|
||||||
import '../entities/entities.dart';
|
import '../entities/entities.dart';
|
||||||
|
|
||||||
class MyUser {
|
class MyUser {
|
||||||
|
|
@ -7,12 +6,11 @@ class MyUser {
|
||||||
String name;
|
String name;
|
||||||
bool hasActiveCart;
|
bool hasActiveCart;
|
||||||
|
|
||||||
MyUser ({
|
MyUser({
|
||||||
required this.userId,
|
required this.userId,
|
||||||
required this.email,
|
required this.email,
|
||||||
required this.name,
|
required this.name,
|
||||||
required this.hasActiveCart,
|
required this.hasActiveCart,
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
static final empty = MyUser(
|
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
|
@override
|
||||||
String toString() {
|
String toString() {
|
||||||
return 'MyUser: $userId, email: $email, name: $name, hasActiveCart: $hasActiveCart}';
|
return 'MyUser: $userId, email: $email, name: $name, hasActiveCart: $hasActiveCart}';
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
16
pubspec.lock
16
pubspec.lock
|
|
@ -695,6 +695,14 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.27.7"
|
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:
|
sky_engine:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description: flutter
|
description: flutter
|
||||||
|
|
@ -740,6 +748,14 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.4.1"
|
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:
|
term_glyph:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ publish_to: 'none'
|
||||||
version: 1.0.0+1
|
version: 1.0.0+1
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.1.0
|
sdk: ^3.1.0
|
||||||
|
|
||||||
dependencies:
|
dependencies:
|
||||||
app_settings: ^7.0.0
|
app_settings: ^7.0.0
|
||||||
bloc: ^8.1.0
|
bloc: ^8.1.0
|
||||||
|
|
@ -16,6 +17,7 @@ dependencies:
|
||||||
firebase_core: ^4.3.0
|
firebase_core: ^4.3.0
|
||||||
firebase_database: ^12.1.3
|
firebase_database: ^12.1.3
|
||||||
fl_chart: ^1.1.1
|
fl_chart: ^1.1.1
|
||||||
|
table_calendar: ^3.1.2
|
||||||
flutter:
|
flutter:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
flutter_bloc: ^8.1.0
|
flutter_bloc: ^8.1.0
|
||||||
|
|
@ -34,6 +36,7 @@ dev_dependencies:
|
||||||
flutter_lints: ^3.0.0
|
flutter_lints: ^3.0.0
|
||||||
flutter_test:
|
flutter_test:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
|
|
||||||
flutter:
|
flutter:
|
||||||
uses-material-design: true
|
uses-material-design: true
|
||||||
assets:
|
assets:
|
||||||
|
|
|
||||||
|
|
@ -5,12 +5,11 @@
|
||||||
// gestures. You can also use WidgetTester to find child widgets in the widget
|
// 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.
|
// 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/material.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:klimatologiot/app.dart';
|
||||||
import 'package:monitoring_repository/monitoring_repository.dart';
|
import 'package:monitoring_repository/monitoring_repository.dart';
|
||||||
import 'package:user_repository/user_repository.dart';
|
import 'package:user_repository/user_repository.dart';
|
||||||
import '../lib/app.dart';
|
|
||||||
|
|
||||||
// import 'package:klimatologiot/main.dart';
|
// import 'package:klimatologiot/main.dart';
|
||||||
|
|
||||||
|
|
@ -49,31 +48,38 @@ class FakeUserRepository implements UserRepository {
|
||||||
}
|
}
|
||||||
|
|
||||||
class FakeMonitoringRepository implements MonitoringRepository {
|
class FakeMonitoringRepository implements MonitoringRepository {
|
||||||
get _db => null;
|
|
||||||
|
|
||||||
// Satu fungsi untuk semua jenis sensor
|
|
||||||
// Kamu cukup masukkan "path" database-nya saja
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
// Jika ingin mengambil data sekali saja (bukan stream)
|
Future<T> getSensorSnapshot<T>(
|
||||||
Future<DataSnapshot> getSensorSnapshot(String path) async {
|
String path,
|
||||||
return await _db.ref(path).get();
|
T Function(Map<dynamic, dynamic>) mapper,
|
||||||
|
) async {
|
||||||
|
return mapper(<dynamic, dynamic>{});
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Stream<DatabaseEvent> getSensorStream(String path) {
|
Stream<T> getSensorStream<T>(
|
||||||
// TODO: implement getSensorStream
|
String path,
|
||||||
throw UnimplementedError();
|
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() {
|
void main() {
|
||||||
testWidgets('App loads', (WidgetTester tester) async {
|
testWidgets('App loads', (WidgetTester tester) async {
|
||||||
final fakeRepo = FakeUserRepository();
|
final fakeRepo = FakeUserRepository();
|
||||||
final fakeMonitoring = FakeUserRepository();
|
final fakeMonitoring = FakeMonitoringRepository();
|
||||||
// Build our app and trigger a frame.
|
// Build our app and trigger a frame.
|
||||||
await tester.pumpWidget(
|
await tester.pumpWidget(
|
||||||
MyApp(fakeRepo, fakeMonitoring as MonitoringRepository),
|
MyApp(fakeRepo, fakeMonitoring),
|
||||||
);
|
);
|
||||||
|
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue