diff --git a/TODO.md b/TODO.md index 828454f..5d401e8 100644 --- a/TODO.md +++ b/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 diff --git a/analysis_options.yaml b/analysis_options.yaml index 0d29021..0c570e0 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -7,6 +7,9 @@ # The following line activates a set of recommended lints for Flutter apps, # packages, and plugins designed to encourage good coding practices. +analyzer: + errors: + unused_local_variable: ignore include: package:flutter_lints/flutter.yaml linter: diff --git a/lib/app_view.dart b/lib/app_view.dart index 3fd5d03..0320ddb 100644 --- a/lib/app_view.dart +++ b/lib/app_view.dart @@ -11,25 +11,23 @@ class MyAppView extends StatelessWidget { @override Widget build(BuildContext context) { return MaterialApp( - title: 'Klimatologi', - debugShowCheckedModeBanner: false, - theme: ThemeData( - colorScheme: ColorScheme.light( - background: Colors.grey.shade100, - onBackground: Colors.black, - primary: Colors.blue, - onPrimary: Colors.white + title: 'Klimatologi', + debugShowCheckedModeBanner: false, + theme: ThemeData( + colorScheme: ColorScheme.light( + surface: Colors.grey.shade100, + onSurface: Colors.black, + primary: Colors.blue, + onPrimary: Colors.white), ), - ), - home: BlocBuilder( - builder: ((context, state){ - if(state.status == AuthenticationStatus.authenticated){ - return HomeScreen(); + home: BlocBuilder( + builder: ((context, state) { + if (state.status == AuthenticationStatus.authenticated) { + return HomeScreen(); } else { - return WelcomeScreen(); + return WelcomeScreen(); } }), - ) - ); + )); } -} \ No newline at end of file +} diff --git a/lib/blocs/authentication_bloc/authentication_bloc.dart b/lib/blocs/authentication_bloc/authentication_bloc.dart index 7929c1e..9ff6e4a 100644 --- a/lib/blocs/authentication_bloc/authentication_bloc.dart +++ b/lib/blocs/authentication_bloc/authentication_bloc.dart @@ -7,20 +7,19 @@ import 'package:user_repository/user_repository.dart'; part 'authentication_event.dart'; part 'authentication_state.dart'; -class AuthenticationBloc extends Bloc { +class AuthenticationBloc + extends Bloc { final UserRepository userRepository; late final StreamSubscription _userSubscription; - AuthenticationBloc({ - required this.userRepository - }) : super(const AuthenticationState.unknown()) { + AuthenticationBloc({required this.userRepository}) + : super(const AuthenticationState.unknown()) { _userSubscription = userRepository.user.listen((user) { add(AuthenticationUserChanged(user)); }); - on((event, emit) { - if(event.user != MyUser.empty) { + if (event.user != null && event.user != MyUser.empty) { emit(AuthenticationState.authenticated(event.user!)); } else { emit(AuthenticationState.unauthenticated()); diff --git a/lib/core/utils/time_series_mapper.dart b/lib/core/utils/time_series_mapper.dart index 3133012..12b1a59 100644 --- a/lib/core/utils/time_series_mapper.dart +++ b/lib/core/utils/time_series_mapper.dart @@ -91,13 +91,46 @@ class TimeSeriesMapper { }); } +/// ========================= + /// 📅 SPECIFIC DATE (24 JAM - TANGGAL KHUSUS) + /// ========================= + static List toSpecificDate({ + required List data, + required DateTime Function(T) getTime, + required double Function(T) getValue, + required DateTime targetDate, + }) { + final sums = List.filled(24, 0.0); + final counts = List.filled(24, 0); + + for (final item in data) { + final time = getTime(item); + + if (_isSameDay(time, targetDate)) { + final hour = time.hour; + sums[hour] += getValue(item); + counts[hour]++; + } + } + + return List.generate(24, (i) { + if (counts[i] == 0) return 0; + return sums[i] / counts[i]; + }); + } + /// ========================= /// 🧠 HELPER /// ========================= + /// Sinkronisasi tanggal untuk menghindari mismatch akibat timezone (UTC vs local). + /// Kita bandingkan berdasarkan UTC. static bool _isSameDay(DateTime a, DateTime b) { - return a.year == b.year && a.month == b.month && a.day == b.day; + final au = a.toUtc(); + final bu = b.toUtc(); + return au.year == bu.year && au.month == bu.month && au.day == bu.day; } + static List smooth(List data) { if (data.length < 3) return data; diff --git a/lib/screens/auth/blocs/sign_in_bloc/sign_in_bloc.dart b/lib/screens/auth/blocs/sign_in_bloc/sign_in_bloc.dart index ef52fb0..aa0fbfb 100644 --- a/lib/screens/auth/blocs/sign_in_bloc/sign_in_bloc.dart +++ b/lib/screens/auth/blocs/sign_in_bloc/sign_in_bloc.dart @@ -13,6 +13,7 @@ class SignInBloc extends Bloc { emit(SignInProcess()); try { await _userRepository.signIn(event.email, event.password); + emit(SignInSuccess()); } catch (e) { emit(SignInFailure()); } @@ -25,7 +26,6 @@ class SignInBloc extends Bloc { emit(SignInSuccess()); } catch (e) { emit(SignInFailure()); - emit(SignInInitial()); } }); diff --git a/lib/screens/auth/views/sign_in_screen.dart b/lib/screens/auth/views/sign_in_screen.dart index 84e359a..b4f00f1 100644 --- a/lib/screens/auth/views/sign_in_screen.dart +++ b/lib/screens/auth/views/sign_in_screen.dart @@ -21,47 +21,68 @@ class _SignInScreenState extends State { bool obscurePassword = true; String? _errorMsg; + @override + void dispose() { + emailController.dispose(); + passwordController.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { return BlocListener( listener: (context, state) { if (state is SignInProcess) { - setState(() { - signInRequired = true; - }); + setState(() => signInRequired = true); } else { - setState(() { - signInRequired = false; - }); + setState(() => signInRequired = false); + if (state is SignInFailure) { - setState(() { - // PERUBAHAN: Pesan error lebih umum karena Google gagal belum tentu soal password - _errorMsg = 'Sign in failed. Please try again.'; - }); + setState(() => _errorMsg = 'Email atau password salah. Coba lagi.'); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(_errorMsg!), + backgroundColor: Colors.red, + ), + ); + } else if (state is SignInSuccess) { + setState(() => _errorMsg = null); } } }, - child: Form( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Form( key: _formKey, child: Column( children: [ const SizedBox(height: 20), + + // Email field SizedBox( - width: MediaQuery.of(context).size.width * 0.9, - child: MyTextField( - controller: emailController, - hintText: 'Email', - obscureText: false, - keyboardType: TextInputType.emailAddress, - prefixIcon: const Icon(CupertinoIcons.mail_solid), - errorMsg: _errorMsg, - validator: (val) { - if (val!.isEmpty) { - return 'Please fill in this field'; - } - return null; - })), + width: MediaQuery.of(context).size.width * 0.9, + child: MyTextField( + controller: emailController, + hintText: 'Email', + obscureText: false, + keyboardType: TextInputType.emailAddress, + prefixIcon: const Icon(CupertinoIcons.mail_solid), + // FIX: tidak lagi pass errorMsg ke field individual + validator: (val) { + if (val == null || val.isEmpty) { + return 'Mohon isi field ini'; + } + // FIX: tambah validasi format email + if (!RegExp(r'^[\w.-]+@[\w.-]+\.\w{2,}$').hasMatch(val)) { + return 'Format email tidak valid'; + } + return null; + }, + ), + ), const SizedBox(height: 10), + + // Password field SizedBox( width: MediaQuery.of(context).size.width * 0.9, child: MyTextField( @@ -70,10 +91,9 @@ class _SignInScreenState extends State { obscureText: obscurePassword, keyboardType: TextInputType.visiblePassword, prefixIcon: const Icon(CupertinoIcons.lock_fill), - errorMsg: _errorMsg, validator: (val) { - if (val!.isEmpty) { - return 'Please fill in this field'; + if (val == null || val.isEmpty) { + return 'Mohon isi field ini'; } return null; }, @@ -81,85 +101,104 @@ class _SignInScreenState extends State { onPressed: () { setState(() { obscurePassword = !obscurePassword; - if (obscurePassword) { - iconPassword = CupertinoIcons.eye_fill; - } else { - iconPassword = CupertinoIcons.eye_slash_fill; - } + iconPassword = obscurePassword + ? CupertinoIcons.eye_fill + : CupertinoIcons.eye_slash_fill; }); }, icon: Icon(iconPassword), ), ), ), - const SizedBox(height: 20), - !signInRequired - ? SizedBox( - width: MediaQuery.of(context).size.width * 0.5, - child: TextButton( - onPressed: () { - if (_formKey.currentState!.validate()) { - context.read().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), - SizedBox( - width: MediaQuery.of(context).size.width * 0.5, - child: TextButton.icon( - onPressed: () { - // == Mengirim event login Google ke Bloc == // - context.read().add(GoogleSignInRequired()); - }, - icon: SizedBox( - height: 20, - width: 20, - child: Image.asset( - 'images/google-icon-logo.png', - fit: BoxFit.contain, + + // FIX: Tombol Sign In dan Google dinonaktifkan saat loading + if (!signInRequired) ...[ + SizedBox( + width: MediaQuery.of(context).size.width * 0.5, + child: TextButton( + onPressed: () { + if (_formKey.currentState!.validate()) { + context.read().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), + ), ), ), - label: const Text( - 'Sign In with Google', - style: TextStyle( - color: Colors.black, - fontSize: 16, - fontWeight: FontWeight.w600), - ), - style: TextButton.styleFrom( + ), + const SizedBox(height: 16), + + // Tombol Google — hanya muncul ketika tidak loading + SizedBox( + width: MediaQuery.of(context).size.width * 0.5, + child: TextButton.icon( + onPressed: () { + context.read().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, backgroundColor: Colors.white, shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(60), - side: const BorderSide(color: Colors.grey))), + borderRadius: BorderRadius.circular(60), + side: const BorderSide(color: Colors.grey), + ), + ), + ), ), - ), + ] else + const CircularProgressIndicator(), + + const SizedBox(height: 20), ], - )), + ), + ), + ), ); } } diff --git a/lib/screens/auth/views/sign_up_screen.dart b/lib/screens/auth/views/sign_up_screen.dart index 5d50a2f..ef6a919 100644 --- a/lib/screens/auth/views/sign_up_screen.dart +++ b/lib/screens/auth/views/sign_up_screen.dart @@ -1,11 +1,10 @@ -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:user_repository/user_repository.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:user_repository/user_repository.dart'; + import '../../../components/my_text_field.dart'; -import '../blocs/sign_up_bloc/sign_up_bloc.dart' as signUp; -// import '../../../blocs/authentication_bloc/authentication_bloc.dart'; -import '../blocs/sign_in_bloc/sign_in_bloc.dart' as signIn; +import '../blocs/sign_up_bloc/sign_up_bloc.dart'; class SignUpScreen extends StatefulWidget { final VoidCallback? onSignInTap; @@ -24,251 +23,207 @@ class _SignUpScreenState extends State { IconData iconPassword = CupertinoIcons.eye_fill; bool obscurePassword = true; bool signUpRequired = false; + // FIX: Hapus 5 variabel password strength yang tidak dipakai - bool containsUpperCase = false; - bool containsLowerCase = false; - bool containsNumber = false; - bool containsSpecialChar = false; - bool contains8Length = false; + @override + void dispose() { + nameController.dispose(); + emailController.dispose(); + passwordController.dispose(); + confirmPasswordController.dispose(); + super.dispose(); + } @override Widget build(BuildContext context) { - return BlocListener( + return BlocListener( listener: (context, state) { - if (state is signUp.SignUpProcess) { - setState(() { - signUpRequired = true; - }); - } else if (state is signUp.SignUpFailure) { - setState(() { - signUpRequired = false; // Matikan loading! - }); - // Tampilkan pesan error lewat Snackbar biar user tahu kenapa gagal + if (state is SignUpProcess) { + setState(() => signUpRequired = true); + } else if (state is SignUpFailure) { + setState(() => signUpRequired = false); ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text(state.message ?? 'An error ocurred'), - backgroundColor: Colors.red), + content: Text(state.message ?? 'Terjadi kesalahan'), + backgroundColor: Colors.red, + ), ); - } else if (state is signUp.SignUpSuccess) { - setState(() { - signUpRequired = false; - }); + } else if (state is SignUpSuccess) { + setState(() => signUpRequired = false); } }, + // FIX: Hapus Scaffold — WelcomeScreen sudah punya Scaffold child: Form( key: _formKey, - child: Scaffold( - backgroundColor: Colors.white, - body: Center( - child: SingleChildScrollView( - child: Column( - children: [ - const SizedBox(height: 30), - Container( - width: MediaQuery.of(context).size.width * 0.92, - padding: const EdgeInsets.symmetric( - horizontal: 20, vertical: 22), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(12), - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(0.04), - blurRadius: 10, - offset: const Offset(0, 4), - ) - ], - ), - 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), + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + 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: 20), - // Name - SizedBox( - width: MediaQuery.of(context).size.width * 0.9, - child: MyTextField( - controller: nameController, - hintText: 'Masukkan nama lengkap', - obscureText: false, - keyboardType: TextInputType.name, - prefixIcon: const Icon(CupertinoIcons.person_fill), - validator: (val) { - if (val == null || val.isEmpty) { - return 'Please fill in this field'; - } else if (val.length > 50) { - return 'Name too long'; - } - return null; - }, - ), - ), - const SizedBox(height: 10), + // Nama + MyTextField( + controller: nameController, + hintText: 'Masukkan nama lengkap', + obscureText: false, + keyboardType: TextInputType.name, + prefixIcon: const Icon(CupertinoIcons.person_fill), + validator: (val) { + if (val == null || val.isEmpty) { + return 'Mohon isi field ini'; + } + if (val.length > 50) return 'Nama terlalu panjang'; + return null; + }, + ), + const SizedBox(height: 10), - // Email - SizedBox( - width: MediaQuery.of(context).size.width * 0.9, - child: MyTextField( - controller: emailController, - hintText: 'nama@email.com', - obscureText: false, - keyboardType: TextInputType.emailAddress, - prefixIcon: const Icon(CupertinoIcons.mail_solid), - validator: (val) { - if (val == null || val.isEmpty) { - return 'Please fill in this field'; - } - return null; - }, - ), - ), - const SizedBox(height: 10), + // Email + MyTextField( + controller: emailController, + hintText: 'nama@email.com', + obscureText: false, + keyboardType: TextInputType.emailAddress, + prefixIcon: const Icon(CupertinoIcons.mail_solid), + validator: (val) { + if (val == null || val.isEmpty) { + return 'Mohon isi field ini'; + } + // FIX: tambah validasi format email + if (!RegExp(r'^[\w.-]+@[\w.-]+\.\w{2,}$').hasMatch(val)) { + return 'Format email tidak valid'; + } + return null; + }, + ), + const SizedBox(height: 10), - // Password - SizedBox( - width: MediaQuery.of(context).size.width * 0.9, - child: MyTextField( - controller: passwordController, - hintText: 'Minimal 6 karakter', - obscureText: obscurePassword, - keyboardType: TextInputType.visiblePassword, - prefixIcon: const Icon(CupertinoIcons.lock_fill), - suffixIcon: IconButton( - onPressed: () { - setState(() { - obscurePassword = !obscurePassword; - iconPassword = obscurePassword - ? CupertinoIcons.eye_fill - : CupertinoIcons.eye_slash_fill; - }); - }, - icon: Icon(iconPassword), + // Password + MyTextField( + controller: passwordController, + hintText: 'Minimal 6 karakter', + obscureText: obscurePassword, + keyboardType: TextInputType.visiblePassword, + prefixIcon: const Icon(CupertinoIcons.lock_fill), + suffixIcon: IconButton( + onPressed: () { + setState(() { + obscurePassword = !obscurePassword; + iconPassword = obscurePassword + ? CupertinoIcons.eye_fill + : CupertinoIcons.eye_slash_fill; + }); + }, + 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().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 - SizedBox( - width: MediaQuery.of(context).size.width * 0.9, - child: 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 '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().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: 16), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Text('Sudah punya akun? '), + TextButton( + onPressed: widget.onSignInTap, + child: const Text( + 'Login di sini', + style: TextStyle(fontWeight: FontWeight.bold), ), ), - const SizedBox(height: 30), ], ), - ), + const SizedBox(height: 16), + ], ), ), ), diff --git a/lib/screens/auth/views/welcome_screen.dart b/lib/screens/auth/views/welcome_screen.dart index 2a5444c..5c11126 100644 --- a/lib/screens/auth/views/welcome_screen.dart +++ b/lib/screens/auth/views/welcome_screen.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import '../../../blocs/authentication_bloc/authentication_bloc.dart'; +import 'package:user_repository/user_repository.dart'; + import '../blocs/sign_in_bloc/sign_in_bloc.dart'; import '../blocs/sign_up_bloc/sign_up_bloc.dart'; import 'sign_in_screen.dart'; @@ -19,84 +20,66 @@ class _WelcomeScreenState extends State @override void initState() { - tabController = TabController(initialIndex: 0, length: 2, vsync: this); super.initState(); + tabController = TabController(initialIndex: 0, length: 2, vsync: this); + } + + @override + void dispose() { + tabController.dispose(); // FIX: dispose controller agar tidak memory leak + super.dispose(); } @override Widget build(BuildContext context) { return Scaffold( backgroundColor: Colors.white, - body: SingleChildScrollView( - child: SizedBox( - height: MediaQuery.of(context).size.height, - child: Stack( - children: [ - Align( - alignment: Alignment.center, - child: SizedBox( - height: MediaQuery.of(context).size.height / 1.8, - child: Column( - children: [ - Padding( - padding: const EdgeInsets.symmetric(horizontal: 50.0), - child: TabBar( - controller: tabController, - unselectedLabelColor: Theme.of(context) - .colorScheme - .onBackground - .withOpacity(0.5), - labelColor: - Theme.of(context).colorScheme.onBackground, - 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( - create: (context) => SignInBloc(context - .read() - .userRepository), - child: const SignInScreen(), - ), - BlocProvider( - create: (context) => SignUpBloc(context - .read() - .userRepository), - child: SignUpScreen(onSignInTap: () { - tabController.animateTo( - 0); // Ini yang bikin dia pindah ke tab Login - }), - ), - ], - )) - ], + body: SafeArea( + child: Column( + children: [ + const SizedBox(height: 24), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 50.0), + child: TabBar( + controller: tabController, + unselectedLabelColor: + Theme.of(context).colorScheme.onSurface.withOpacity(0.5), + labelColor: Theme.of(context).colorScheme.onSurface, + tabs: const [ + Padding( + padding: EdgeInsets.all(12.0), + child: Text('Sign In', style: TextStyle(fontSize: 18)), ), - ), - ) - ], - ), + 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( + create: (context) => + // FIX: akses UserRepository langsung, bukan via AuthBloc + SignInBloc(context.read()), + child: const SignInScreen(), + ), + BlocProvider( + create: (context) => + SignUpBloc(context.read()), + child: SignUpScreen( + onSignInTap: () => tabController.animateTo(0), + ), + ), + ], + ), + ), + ], ), ), ); diff --git a/lib/screens/home/views/home_screen.dart b/lib/screens/home/views/home_screen.dart index 71a1af0..a495092 100644 --- a/lib/screens/home/views/home_screen.dart +++ b/lib/screens/home/views/home_screen.dart @@ -8,6 +8,7 @@ import '../../monitoring/evaporasi/blocs/evaporasi_bloc.dart'; import '../../monitoring/atmospheric_conditions/blocs/atmospheric_conditions_bloc.dart'; import '../widgets/notification_panel.dart'; import '../widgets/sensor_grid.dart'; +import '../widgets/dashboard_charts.dart'; import 'main_drawer.dart'; class HomeScreen extends StatefulWidget { @@ -199,6 +200,9 @@ class _HomeBody extends StatelessWidget { ), const SizedBox(height: 12), const SensorGrid(), + const SizedBox(height: 24), // ← TAMBAH + const DashboardCharts(), // ← TAMBAH + const SizedBox(height: 16), ], ), ); diff --git a/lib/screens/home/widgets/dashboard_charts.dart b/lib/screens/home/widgets/dashboard_charts.dart new file mode 100644 index 0000000..4dc5c45 --- /dev/null +++ b/lib/screens/home/widgets/dashboard_charts.dart @@ -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 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 createState() => _DashboardChartsState(); +} + +class _DashboardChartsState extends State { + String _period = "Hari Ini"; + + void _onPeriodChanged(String p) { + setState(() => _period = p); + context.read().add(WindSpeedPeriodChanged(p)); + context.read().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( + 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( + 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( + 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 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 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 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 = []; + 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; +} diff --git a/lib/screens/monitoring/evaporasi/blocs/evaporasi_bloc.dart b/lib/screens/monitoring/evaporasi/blocs/evaporasi_bloc.dart index 7758d67..4e28b7d 100644 --- a/lib/screens/monitoring/evaporasi/blocs/evaporasi_bloc.dart +++ b/lib/screens/monitoring/evaporasi/blocs/evaporasi_bloc.dart @@ -23,26 +23,29 @@ class EvaporasiBloc extends Bloc { on(_onStarted); on<_EvaporasiRealtimeUpdated>(_onRealtimeUpdated); on(_onPeriodChanged); + on(_onDateSelected); + on(_onViewModeChanged); } - /// ========================= - /// 🚀 START - /// ========================= Future _onStarted( WatchEvaporasiStarted event, Emitter emit, ) async { emit(state.copyWith(isLoading: true)); + // Ambil history yang akan dipakai untuk list + agregasi grafik. final history = await _repository.getSensorHistory( - 'evaporasi/history', + 'Monitoring/History', (json) => Evaporasi.fromJson(json), ); + final listData = List.from(history) + ..sort((a, b) => a.timestamp.compareTo(b.timestamp)); + final dailyGraph = TimeSeriesMapper.toDaily( data: history, getTime: (e) => e.timestamp, - getValue: (e) => e.evaporasi, + getValue: (e) => e.evaporasi, // ⚠️ sesuaikan nama field ); final dailyTempGraph = TimeSeriesMapper.toDaily( @@ -51,17 +54,51 @@ class EvaporasiBloc extends Bloc { getValue: (e) => e.suhu, ); - // Hitung status dari data terakhir history jika ada + final weeklyGraph = TimeSeriesMapper.toWeekly( + data: history, + getTime: (e) => e.timestamp, + getValue: (e) => e.evaporasi, + ); + + final monthlyGraph = TimeSeriesMapper.toMonthly( + data: history, + getTime: (e) => e.timestamp, + getValue: (e) => e.evaporasi, + ); + + final weeklyTemp = TimeSeriesMapper.toWeekly( + data: history, + getTime: (e) => e.timestamp, + getValue: (e) => e.suhu, + ); + + final monthlyTemp = TimeSeriesMapper.toMonthly( + data: history, + getTime: (e) => e.timestamp, + getValue: (e) => e.suhu, + ); + final lastValue = history.isNotEmpty ? history.last.evaporasi : 0.0; + final lastWaterLevel = history.isNotEmpty ? history.last.tinggiAir : 0.0; + final lastTemperature = history.isNotEmpty ? history.last.suhu : 0.0; final (status, rain) = _computeWeatherStatus(lastValue); _emitEvaporasiAlert(status, rain, lastValue); emit(state.copyWith( history: history, + currentValue: lastValue, + waterLevel: lastWaterLevel, + temperature: lastTemperature, dailyValues: dailyGraph, dailyTemperatures: dailyTempGraph, + weeklyValues: weeklyGraph, + monthlyValues: monthlyGraph, + weeklyTemperatures: weeklyTemp, + monthlyTemperatures: monthlyTemp, + chartLabels: _buildChartLabels(period: 'Hari Ini'), weatherStatus: status, willRain: rain, + listData: listData, isLoading: false, )); @@ -71,20 +108,35 @@ class EvaporasiBloc extends Bloc { .listen((data) => add(_EvaporasiRealtimeUpdated(data))); } - /// ========================= - /// ⚡ REALTIME - /// ========================= void _onRealtimeUpdated( _EvaporasiRealtimeUpdated event, Emitter emit, ) { - final updated = List.from(state.dailyValues); - final updatedTemp = List.from(state.dailyTemperatures); - final index = DateTime.now().hour; + // Hindari update dobel: jika timestamp event sama dengan yang terakhir, jangan ubah bucket. + final previous = state.history.isNotEmpty ? state.history.last.timestamp : null; + final isDuplicate = + previous != null && event.data.timestamp.toUtc() == previous.toUtc(); - if (index < updated.length) { - updated[index] = event.data.evaporasi; - updatedTemp[index] = event.data.suhu; + // Update bucket berdasarkan timestamp event (bukan jam lokal sekarang). + final updated = isDuplicate ? state.dailyValues : List.from(state.dailyValues); + final updatedTemp = isDuplicate + ? state.dailyTemperatures + : List.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); @@ -101,20 +153,18 @@ class EvaporasiBloc extends Bloc { )); } - /// ========================= - /// 📊 PERIOD - /// ========================= Future _onPeriodChanged( EvaporasiPeriodChanged event, Emitter emit, ) async { emit(state.copyWith(isLoading: true, selectedPeriod: event.period)); + final history = state.history; List updated; List updatedTemp; - if (event.period == "Minggu Ini") { + if (event.period == 'Minggu Ini') { updated = TimeSeriesMapper.toWeekly( data: history, getTime: (e) => e.timestamp, @@ -125,7 +175,7 @@ class EvaporasiBloc extends Bloc { getTime: (e) => e.timestamp, getValue: (e) => e.suhu, ); - } else if (event.period == "Bulan Ini") { + } else if (event.period == 'Bulan Ini') { updated = TimeSeriesMapper.toMonthly( data: history, getTime: (e) => e.timestamp, @@ -149,23 +199,70 @@ class EvaporasiBloc extends Bloc { ); } - // 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 _onDateSelected( + EvaporasiDateSelected event, + Emitter emit, + ) async { + emit(state.copyWith( + isLoading: true, + selectedDate: event.date, + viewMode: EvaporasiViewMode.customDate, + )); + + final history = state.history; + + final updated = TimeSeriesMapper.toSpecificDate( + data: history, + getTime: (e) => e.timestamp, + getValue: (e) => e.evaporasi, + targetDate: event.date, + ); + + final updatedTemp = TimeSeriesMapper.toSpecificDate( + data: history, + getTime: (e) => e.timestamp, + getValue: (e) => e.suhu, + targetDate: event.date, + ); + emit(state.copyWith( dailyValues: updated, dailyTemperatures: updatedTemp, isLoading: false, + // jangan hilangkan list + listData: state.listData, + history: state.history, )); } + Future _onViewModeChanged( + EvaporasiViewModeChanged event, + Emitter emit, + ) async { + if (event.mode == EvaporasiViewMode.period) { + add(EvaporasiPeriodChanged(state.selectedPeriod)); + } else { + emit(state.copyWith(viewMode: event.mode)); + } + } + @override Future close() async { await _subscription?.cancel(); return super.close(); } - /// ========================= - /// 🌤️ HELPER: Status Cuaca - /// ========================= static (String status, bool willRain) _computeWeatherStatus(double value) { if (value <= 5.0) return ('Baik', false); if (value <= 10.0) return ('Sedang', true); @@ -185,7 +282,7 @@ class EvaporasiBloc extends Bloc { message = 'Evaporasi ${value.toStringAsFixed(1)} mm — status sedang, potensi hujan'; } else { - severity = AlertSeverity.info; // normal → bersihkan alert + severity = AlertSeverity.info; message = ''; } @@ -199,9 +296,23 @@ class EvaporasiBloc extends Bloc { ), )); } + + List _buildChartLabels({required String period}) { + if (period == 'Minggu Ini') { + return const ['Sen', 'Sel', 'Rab', 'Kam', 'Jum', 'Sab', 'Min']; + } + + final now = DateTime.now(); + if (period == 'Bulan Ini') { + final daysInMonth = DateTime(now.year, now.month + 1, 0).day; + return List.generate(daysInMonth, (i) => '${i + 1}'); + } + + // Hari Ini / Tanggal Khusus => 24 jam + return List.generate(24, (i) => '${i.toString().padLeft(2, '0')}:00'); + } } -/// INTERNAL EVENT class _EvaporasiRealtimeUpdated extends EvaporasiEvent { final Evaporasi data; diff --git a/lib/screens/monitoring/evaporasi/blocs/evaporasi_event.dart b/lib/screens/monitoring/evaporasi/blocs/evaporasi_event.dart index 735954a..cbf007d 100644 --- a/lib/screens/monitoring/evaporasi/blocs/evaporasi_event.dart +++ b/lib/screens/monitoring/evaporasi/blocs/evaporasi_event.dart @@ -19,3 +19,23 @@ class EvaporasiPeriodChanged extends EvaporasiEvent { @override List get props => [period]; } + +/// 📅 PILIH TANGGAL KHUSUS (Custom Date Picker) +class EvaporasiDateSelected extends EvaporasiEvent { + final DateTime date; + + const EvaporasiDateSelected(this.date); + + @override + List get props => [date]; +} + +/// 🔄 KEMBALI KE MODE PERIOD +class EvaporasiViewModeChanged extends EvaporasiEvent { + final EvaporasiViewMode mode; + + const EvaporasiViewModeChanged(this.mode); + + @override + List get props => [mode]; +} diff --git a/lib/screens/monitoring/evaporasi/blocs/evaporasi_state.dart b/lib/screens/monitoring/evaporasi/blocs/evaporasi_state.dart index 742ce96..d84efea 100644 --- a/lib/screens/monitoring/evaporasi/blocs/evaporasi_state.dart +++ b/lib/screens/monitoring/evaporasi/blocs/evaporasi_state.dart @@ -1,17 +1,35 @@ part of 'evaporasi_bloc.dart'; +enum EvaporasiViewMode { period, customDate } + class EvaporasiState extends Equatable { final double currentValue; // nilai evaporasi realtime final double temperature; // suhu (opsional dari firebase) final double waterLevel; // tinggi air final String selectedPeriod; + final DateTime? selectedDate; // tanggal spesifik untuk custom date + final EvaporasiViewMode viewMode; // period vs custom date + + final List dailyValues; // untuk grafik evaporasi (default) + final List weeklyValues; + final List monthlyValues; - final List dailyValues; // untuk grafik evaporasi final List dailyTemperatures; // untuk grafik suhu - final List history; + final List weeklyTemperatures; + final List monthlyTemperatures; - final String weatherStatus; // Baik / Sedang / Buruk - final bool willRain; // true jika status Sedang/Buruk + /// Label X-axis sesuai agregasi period + /// Contoh: Hari Ini => ["00:00","01:00",...] + /// Minggu Ini => ["Sen","Sel",...] + /// Bulan Ini => ["1","2",...] + final List chartLabels; + + /// Data untuk list (timestamp asli dari firebase) + final List listData; + + final List history; + final String weatherStatus; + final bool willRain; final bool isLoading; @@ -19,11 +37,19 @@ class EvaporasiState extends Equatable { this.currentValue = 0.0, this.temperature = 0.0, this.waterLevel = 0.0, - this.selectedPeriod = "Hari Ini", + this.selectedPeriod = 'Hari Ini', + this.selectedDate, + this.viewMode = EvaporasiViewMode.period, this.dailyValues = const [], + this.weeklyValues = const [], + this.monthlyValues = const [], this.dailyTemperatures = const [], + this.weeklyTemperatures = const [], + this.monthlyTemperatures = const [], + this.chartLabels = const [], + this.listData = const [], this.history = const [], - this.weatherStatus = "Baik", + this.weatherStatus = 'Baik', this.willRain = false, this.isLoading = true, }); @@ -33,8 +59,16 @@ class EvaporasiState extends Equatable { double? temperature, double? waterLevel, String? selectedPeriod, + DateTime? selectedDate, + EvaporasiViewMode? viewMode, List? dailyValues, + List? weeklyValues, + List? monthlyValues, List? dailyTemperatures, + List? weeklyTemperatures, + List? monthlyTemperatures, + List? chartLabels, + List? listData, List? history, String? weatherStatus, bool? willRain, @@ -45,8 +79,16 @@ class EvaporasiState extends Equatable { temperature: temperature ?? this.temperature, waterLevel: waterLevel ?? this.waterLevel, selectedPeriod: selectedPeriod ?? this.selectedPeriod, + selectedDate: selectedDate ?? this.selectedDate, + viewMode: viewMode ?? this.viewMode, dailyValues: dailyValues ?? this.dailyValues, + weeklyValues: weeklyValues ?? this.weeklyValues, + monthlyValues: monthlyValues ?? this.monthlyValues, dailyTemperatures: dailyTemperatures ?? this.dailyTemperatures, + weeklyTemperatures: weeklyTemperatures ?? this.weeklyTemperatures, + monthlyTemperatures: monthlyTemperatures ?? this.monthlyTemperatures, + chartLabels: chartLabels ?? this.chartLabels, + listData: listData ?? this.listData, history: history ?? this.history, weatherStatus: weatherStatus ?? this.weatherStatus, willRain: willRain ?? this.willRain, @@ -55,16 +97,25 @@ class EvaporasiState extends Equatable { } @override - List get props => [ + List get props => [ currentValue, temperature, waterLevel, selectedPeriod, + selectedDate, + viewMode, dailyValues, + weeklyValues, + monthlyValues, dailyTemperatures, + weeklyTemperatures, + monthlyTemperatures, + chartLabels, + listData, history, weatherStatus, willRain, isLoading, ]; } + diff --git a/lib/screens/monitoring/evaporasi/views/evaporasi_screen.dart b/lib/screens/monitoring/evaporasi/views/evaporasi_screen.dart index 629029b..3cdfdca 100644 --- a/lib/screens/monitoring/evaporasi/views/evaporasi_screen.dart +++ b/lib/screens/monitoring/evaporasi/views/evaporasi_screen.dart @@ -1,14 +1,21 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:intl/intl.dart'; + import '../blocs/evaporasi_bloc.dart'; -import 'widgets/evaporasi_chart_widget.dart'; -import 'widgets/evaporasi_period_selector.dart'; import '../../shared/utils/pdf/pdf_export_service.dart'; import '../../shared/widgets/export_pdf_button.dart'; +import 'widgets/evaporasi_period_selector.dart'; +import 'widgets/evaporasi_chart_widget.dart'; -class EvaporasiScreen extends StatelessWidget { +class EvaporasiScreen extends StatefulWidget { const EvaporasiScreen({super.key}); + @override + State createState() => _EvaporasiScreenState(); +} + +class _EvaporasiScreenState extends State { @override Widget build(BuildContext context) { return Scaffold( @@ -48,18 +55,55 @@ class EvaporasiScreen extends StatelessWidget { const SizedBox(height: 25), _statusCard(state), const SizedBox(height: 25), - const Text("Tren Evaporasi & Suhu", - style: - TextStyle(fontSize: 18, fontWeight: FontWeight.bold)), - const SizedBox(height: 15), - const EvaporasiPeriodSelector(), - const SizedBox(height: 15), - EvaporasiChartWidget( - dailyValues: state.dailyValues, - dailyTemperatures: state.dailyTemperatures, - period: state.selectedPeriod, + const Text( + "Tren Evaporasi & Suhu", + style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), ), - const SizedBox(height: 25), + const SizedBox(height: 15), + // Period selector with date picker + Builder( + builder: (context) { + final state = context.watch().state; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (state.viewMode == EvaporasiViewMode.customDate && + state.selectedDate != null) + Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Text( + "📅 ${_formatDateInfo(state.selectedDate!)}", + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: Colors.blue.shade700, + ), + ), + ), + const EvaporasiPeriodSelector(), + ], + ); + }, + ), + const SizedBox(height: 15), + // Chart + Builder( + builder: (context) { + final state = context.watch().state; + return EvaporasiChartWidget( + dailyValues: state.dailyValues, + dailyTemperatures: state.dailyTemperatures, + period: state.viewMode == EvaporasiViewMode.customDate + ? "Tanggal Khusus" + : state.selectedPeriod, + chartLabels: state.chartLabels, + ); + }, + ), + const SizedBox(height: 20), + // List data (mengikuti chart: period vs custom date) + _evaporasiList(state), + const SizedBox(height: 10), ExportPdfButton( onExport: () => PdfExportService.evaporasi( evaporasi: state.currentValue, @@ -160,7 +204,7 @@ class EvaporasiScreen extends StatelessWidget { } /// ========================= - /// 🌤️ STATUS CARD + /// 📈 STATUS CARD /// ========================= Widget _statusCard(EvaporasiState state) { Color statusColor; @@ -182,13 +226,23 @@ class EvaporasiScreen extends StatelessWidget { break; } + // Teks peringatan khusus evaporasi (normal/sedang/tinggi) + final String warningText; + if (state.weatherStatus == 'Baik') { + warningText = 'Normal — evaporasi stabil, risiko dampak rendah.'; + } else if (state.weatherStatus == 'Sedang') { + warningText = 'Sedang — evaporasi mulai tinggi, pantau kondisi cuaca.'; + } else { + warningText = + 'Tinggi — evaporasi signifikan, berpotensi memengaruhi kondisi lingkungan.'; + } + return Container( width: double.infinity, - padding: const EdgeInsets.all(20), + padding: const EdgeInsets.all(16), decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(20), - border: Border.all(color: statusColor.withOpacity(0.3), width: 1.5), ), child: Row( children: [ @@ -204,7 +258,11 @@ class EvaporasiScreen extends StatelessWidget { ), const SizedBox(height: 4), Text( - state.weatherStatus, + state.weatherStatus == 'Baik' + ? 'Normal' + : state.weatherStatus == 'Sedang' + ? 'Sedang' + : 'Tinggi', style: TextStyle( fontSize: 20, fontWeight: FontWeight.bold, @@ -212,9 +270,9 @@ class EvaporasiScreen extends StatelessWidget { ), ), if (state.willRain) - const Text( - "⚠️ Potensi hujan tinggi", - style: TextStyle( + Text( + warningText, + style: const TextStyle( fontSize: 12, color: Colors.red, fontWeight: FontWeight.w500, @@ -227,4 +285,158 @@ class EvaporasiScreen extends StatelessWidget { ), ); } + + /// ========================= + /// 🧾 LIST DATA EVAPORASI + /// ========================= + Widget _evaporasiList(EvaporasiState state) { + final data = state.viewMode == EvaporasiViewMode.customDate && + state.selectedDate != null + ? state.history + .where((e) => + e.timestamp.year == state.selectedDate!.year && + e.timestamp.month == state.selectedDate!.month && + e.timestamp.day == state.selectedDate!.day) + .toList() + : state.history; + + if (data.isEmpty) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(20), + ), + child: const Text( + 'Belum ada data evaporasi', + style: TextStyle(color: Colors.grey), + ), + ); + } + + return Container( + width: double.infinity, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(20), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'List Data Evaporasi', + style: TextStyle(fontSize: 14, fontWeight: FontWeight.bold), + ), + const SizedBox(height: 12), + SizedBox( + height: 280, + child: ListView.separated( + itemCount: data.length, + itemBuilder: (context, index) { + final e = data[index]; + final dateLabel = + '${DateFormat('dd MMM yyyy', 'id_ID').format(e.timestamp)} • ${DateFormat('HH:mm:ss', 'id_ID').format(e.timestamp)}'; + + return Padding( + padding: const EdgeInsets.symmetric(vertical: 10), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Text( + dateLabel, + style: const TextStyle( + fontSize: 12, + color: Colors.black87, + ), + overflow: TextOverflow.ellipsis, + ), + ), + const SizedBox(width: 12), + Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + '${e.evaporasi.toStringAsFixed(1)} mm', + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.w700, + color: Colors.blue, + ), + ), + const SizedBox(height: 4), + Text( + 'Tinggi Air: ${e.tinggiAir.toStringAsFixed(1)} cm', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: Colors.blue.shade700, + ), + ), + const SizedBox(height: 4), + Text( + 'Suhu: ${e.suhu.toStringAsFixed(1)} °C', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: Colors.orange.shade700, + ), + ), + const SizedBox(height: 4), + Text( + _statusTextForHistory(e.evaporasi), + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w700, + color: _statusColorForHistory(e.evaporasi), + ), + ), + ], + ), + ], + ), + ); + }, + separatorBuilder: (_, __) => const Divider(height: 1), + ), + ), + ], + ), + ); + } + + /// ========================= + /// 📅 FORMAT DATE INFO (for custom date display) + /// ========================= + String _statusTextForHistory(double evaporasi) { + if (evaporasi <= 5.0) return 'Status: Normal'; + if (evaporasi <= 10.0) return 'Status: Sedang'; + return 'Status: Tinggi'; + } + + Color _statusColorForHistory(double evaporasi) { + if (evaporasi <= 5.0) return Colors.green; + if (evaporasi <= 10.0) return Colors.orange; + return Colors.red; + } + + String _formatDateInfo(DateTime date) { + + final now = DateTime.now(); + + final today = DateTime(now.year, now.month, now.day); + final yesterday = today.subtract(const Duration(days: 1)); + final selected = DateTime(date.year, date.month, date.day); + + if (selected == today) { + return "Hari Ini"; + } else if (selected == yesterday) { + return "Kemarin"; + } else { + return DateFormat('EEEE, d MMMM yyyy', 'id_ID').format(date); + } + } } + diff --git a/lib/screens/monitoring/evaporasi/views/widgets/evaporasi_chart_widget.dart b/lib/screens/monitoring/evaporasi/views/widgets/evaporasi_chart_widget.dart index fe0a686..209b0a4 100644 --- a/lib/screens/monitoring/evaporasi/views/widgets/evaporasi_chart_widget.dart +++ b/lib/screens/monitoring/evaporasi/views/widgets/evaporasi_chart_widget.dart @@ -1,51 +1,294 @@ import 'package:fl_chart/fl_chart.dart'; import 'package:flutter/material.dart'; +const _tooltipBgColor = Colors.black87; + + class EvaporasiChartWidget extends StatelessWidget { + final List dailyValues; final List dailyTemperatures; final String period; + final List chartLabels; const EvaporasiChartWidget({ super.key, required this.dailyValues, required this.dailyTemperatures, required this.period, + required this.chartLabels, }); - double _getMaxY(List data) { - if (data.isEmpty) return 10; - final max = data.reduce((a, b) => a > b ? a : b); - return (max + 5).clamp(10, 100); + double _safeValue(double value) { + if (value.isNaN || value.isInfinite) return 0.0; + return value < 0 ? 0.0 : value; } - List _safeData(List data) { - return data.map((e) { - if (e.isNaN || e.isInfinite) return 0.0; - return e < 0 ? 0.0 : e; + double _minOf(List values) { + if (values.isEmpty) return 0.0; + return values.map(_safeValue).reduce((a, b) => a < b ? a : b); + } + + double _maxOf(List 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 _evapSpots() { + return dailyValues.asMap().entries.map((entry) { + return FlSpot(entry.key.toDouble(), _safeValue(entry.value)); }).toList(); } + /// Mapping suhu (°C) -> posisi Y internal agar bisa ditampilkan dalam chart + /// yang sama dengan skala evaporasi (mm). + double _tempToEvapScale({ + required double temp, + required double evapMin, + required double evapMax, + required double tempMin, + required double tempMax, + }) { + // Hindari pembagian nol + final tempRange = (tempMax - tempMin); + if (tempRange.abs() < 1e-9) return evapMin; + + final normalized = (temp - tempMin) / tempRange; // 0..1 (secara ideal) + final scaled = evapMin + normalized * (evapMax - evapMin); + return scaled; + } + + /// Reverse mapping Y internal (skala evaporasi) -> suhu asli (°C) + double _evapScaleToTemp({ + required double yEvap, + required double evapMin, + required double evapMax, + required double tempMin, + required double tempMax, + }) { + final evapRange = (evapMax - evapMin); + if (evapRange.abs() < 1e-9) return tempMin; + + final normalized = (yEvap - evapMin) / evapRange; + return tempMin + normalized * (tempMax - tempMin); + } + + String _getBottomLabel(int index) { + if (chartLabels.isEmpty || index < 0 || index >= chartLabels.length) { + return index.toString(); + } + return chartLabels[index]; + } + @override Widget build(BuildContext context) { - final safeValues = _safeData(dailyValues); - final safeTemps = _safeData(dailyTemperatures); + if (dailyValues.isEmpty && dailyTemperatures.isEmpty) { + 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 - final maxY = _getMaxY([ - ...safeValues, - ...safeTemps, - ]); + // Hitung range masing-masing agar axis kanan (°C) masuk akal. + final evapMinRaw = _minOf(dailyValues); + final evapMaxRaw = _maxOf(dailyValues); + final tempMinRaw = _minOf(dailyTemperatures); + final tempMaxRaw = _maxOf(dailyTemperatures); + + // Evaporasi mm biasanya >= 0, kita pakai min 0 agar estetik. + final evapMin = 0.0; + final evapMax = _clampDouble(evapMaxRaw * 1.3, 10.0, 100.0); + + // Suhu bisa saja 0 jika data kosong; tetap aman. + final tempMin = tempMinRaw; + final tempMax = tempMaxRaw == tempMinRaw ? tempMinRaw + 1 : tempMaxRaw; + + final evapSpotsAll = _evapSpots(); + + // Deduplicate X=hour agar garis tidak kelihatan dobel/acak. + final Map 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 tempByX = {}; + for (final entry in dailyTemperatures.asMap().entries) { + tempByX[entry.key] = _safeValue(entry.value); + } + final tempSpots = tempByX.entries.map((entry) { + final x = entry.key.toDouble(); + final temp = entry.value; + final y = _tempToEvapScale( + temp: temp, + evapMin: evapMin, + evapMax: evapMax, + tempMin: tempMin, + tempMax: tempMax, + ); + return FlSpot(x, y); + }).toList(); + + final evapSpots = dedupEvapSpots + .map((e) => FlSpot(e.key.toDouble(), e.value)) + .toList(); + + if (evapSpots.isEmpty && tempSpots.isEmpty) { + return const SizedBox.shrink(); + } + + double _getRightTitle(double y) { + return _evapScaleToTemp( + yEvap: y, + evapMin: evapMin, + evapMax: evapMax, + tempMin: tempMin, + tempMax: tempMax, + ); + } + + + final chart = LineChart( + LineChartData( + minY: evapMin, + maxY: evapMax, + gridData: FlGridData(show: false), + borderData: FlBorderData(show: false), + titlesData: FlTitlesData( + bottomTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + reservedSize: 32, + interval: 1, + getTitlesWidget: (value, meta) { + final index = value.toInt(); + return Padding( + padding: const EdgeInsets.only(top: 8), + child: Text( + _getBottomLabel(index), + style: const TextStyle(color: Colors.grey, fontSize: 10), + ), + ); + }, + ), + ), + leftTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + reservedSize: 40, + interval: (evapMax - evapMin) / 4, + getTitlesWidget: (value, meta) { + final v = value; + return Text( + '${v.toStringAsFixed(0)}', + style: const TextStyle(color: Colors.blueGrey, fontSize: 10), + ); + }, + ), + ), + rightTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + reservedSize: 40, + interval: (evapMax - evapMin) / 4, + getTitlesWidget: (value, meta) { + final t = _getRightTitle(value); + return Text( + '${t.toStringAsFixed(0)}', + style: const TextStyle(color: Colors.brown, fontSize: 10), + ); + }, + ), + ), + topTitles: const AxisTitles( + sideTitles: SideTitles(showTitles: false), + ), + ), + lineTouchData: LineTouchData( + handleBuiltInTouches: true, + touchTooltipData: LineTouchTooltipData( + getTooltipItems: (spots) { + + + return spots.map((spot) { + // fl_chart tidak mengekspos warna ke tooltip spot pada tipe LineBarSpot + // jadi kita tampilkan dua informasi sekaligus untuk memudahkan dosen. + final temp = _evapScaleToTemp( + yEvap: spot.y, + evapMin: evapMin, + evapMax: evapMax, + tempMin: tempMin, + tempMax: tempMax, + ); + return LineTooltipItem( + 'Evap: ${spot.y.toStringAsFixed(1)} mm\nSuhu: ${temp.toStringAsFixed(1)} °C', + const TextStyle(color: Colors.white, fontSize: 12), + ); + }).toList(); + }, + ), + ), + lineBarsData: [ + if (evapSpots.isNotEmpty) + LineChartBarData( + spots: evapSpots, + isCurved: true, + color: Colors.blue.shade700, + barWidth: 3, + dotData: FlDotData(show: false), + belowBarData: BarAreaData( + show: true, + gradient: LinearGradient( + colors: [ + Colors.blue.withAlpha(64), + Colors.blue.withAlpha(13), + ], + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + ), + ), + ), + if (tempSpots.isNotEmpty) + LineChartBarData( + spots: tempSpots, + isCurved: true, + color: Colors.orange.shade700, + barWidth: 3, + dotData: FlDotData(show: false), + ), + ], + ), + ); return Container( - height: 280, - padding: const EdgeInsets.fromLTRB(10, 20, 20, 10), + height: 340, + padding: const EdgeInsets.all(12), decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(25), boxShadow: [ BoxShadow( - color: Colors.black.withOpacity(0.05), + color: Colors.black.withAlpha(13), blurRadius: 10, offset: const Offset(0, 5), ) @@ -55,196 +298,34 @@ class EvaporasiChartWidget extends StatelessWidget { children: [ // Legend Row( - mainAxisAlignment: MainAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ - _legendItem(Colors.blue.shade700, "Evaporasi (mm)"), - const SizedBox(width: 20), - _legendItem(Colors.orange.shade600, "Suhu (°C)"), + Row( + children: [ + 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( - child: LineChart( - duration: const Duration(milliseconds: 600), - curve: Curves.easeOutCubic, - LineChartData( - minY: 0, - maxY: maxY, - gridData: const FlGridData(show: false), - borderData: FlBorderData(show: false), - titlesData: FlTitlesData( - show: true, - rightTitles: const AxisTitles( - sideTitles: SideTitles(showTitles: false)), - topTitles: const AxisTitles( - sideTitles: SideTitles(showTitles: false)), - leftTitles: const AxisTitles( - sideTitles: SideTitles(showTitles: false)), - bottomTitles: AxisTitles( - sideTitles: SideTitles( - showTitles: true, - reservedSize: 30, - interval: _getInterval(), - getTitlesWidget: (value, meta) { - return SideTitleWidget( - meta: meta, - space: 8, - child: Text( - _getBottomTitle(value), - style: const TextStyle( - color: Colors.grey, fontSize: 10), - ), - ); - }, - ), - ), - ), - lineBarsData: [ - // === Garis Evaporasi === - LineChartBarData( - spots: safeValues.asMap().entries.map((e) { - return FlSpot(e.key.toDouble(), e.value); - }).toList(), - isCurved: true, - curveSmoothness: 0.2, - preventCurveOverShooting: true, - color: Colors.blue.shade700, - barWidth: 3, - isStrokeCapRound: true, - dotData: FlDotData( - show: true, - getDotPainter: (spot, percent, bar, index) { - if (index == safeValues.length - 1) { - return FlDotCirclePainter( - radius: 5, - color: Colors.blue.shade900, - strokeWidth: 2, - strokeColor: Colors.white, - ); - } - return FlDotCirclePainter(radius: 0); - }, - ), - belowBarData: BarAreaData( - show: true, - gradient: LinearGradient( - colors: [ - Colors.blue.withOpacity(0.25), - Colors.blue.withOpacity(0.05), - Colors.transparent - ], - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - ), - ), - ), - // === Garis Suhu === - LineChartBarData( - spots: safeTemps.asMap().entries.map((e) { - return FlSpot(e.key.toDouble(), e.value); - }).toList(), - isCurved: true, - curveSmoothness: 0.2, - preventCurveOverShooting: true, - color: Colors.orange.shade600, - barWidth: 3, - isStrokeCapRound: true, - dotData: FlDotData( - show: true, - getDotPainter: (spot, percent, bar, index) { - if (index == safeTemps.length - 1) { - return FlDotCirclePainter( - radius: 5, - color: Colors.orange.shade800, - strokeWidth: 2, - strokeColor: Colors.white, - ); - } - return FlDotCirclePainter(radius: 0); - }, - ), - belowBarData: BarAreaData( - show: true, - gradient: LinearGradient( - colors: [ - Colors.orange.withOpacity(0.25), - Colors.orange.withOpacity(0.05), - Colors.transparent - ], - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - ), - ), - ), - ], - lineTouchData: LineTouchData( - handleBuiltInTouches: true, - touchTooltipData: LineTouchTooltipData( - getTooltipItems: (touchedSpots) { - return touchedSpots.map((spot) { - final isEvaporasi = spot.barIndex == 0; - final label = isEvaporasi ? "Evaporasi" : "Suhu"; - final unit = isEvaporasi ? "mm" : "°C"; - return LineTooltipItem( - "$label: ${spot.y.toStringAsFixed(1)} $unit", - TextStyle( - color: isEvaporasi - ? Colors.blue.shade100 - : Colors.orange.shade100, - fontWeight: FontWeight.bold, - ), - ); - }).toList(); - }, - ), - ), - ), + child: ClipRRect( + borderRadius: BorderRadius.circular(25), + child: chart, ), ), ], ), ); } - - 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; - } } diff --git a/lib/screens/monitoring/evaporasi/views/widgets/evaporasi_date_picker.dart b/lib/screens/monitoring/evaporasi/views/widgets/evaporasi_date_picker.dart new file mode 100644 index 0000000..ebf6d22 --- /dev/null +++ b/lib/screens/monitoring/evaporasi/views/widgets/evaporasi_date_picker.dart @@ -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 createState() => _EvaporasiDatePickerState(); +} + +class _EvaporasiDatePickerState extends State { + CalendarFormat _calendarFormat = CalendarFormat.month; + DateTime _focusedDay = DateTime.now(); + DateTime? _selectedDay; + + @override + void initState() { + super.initState(); + _selectedDay = _focusedDay; + } + + @override + Widget build(BuildContext context) { + return Container( + height: 400, + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(20), + ), + child: Column( + children: [ + // Header with close button + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + TextButton( + onPressed: () { + // Kembali ke mode period DAN TUTUP bottom sheet + context.read().add( + const EvaporasiViewModeChanged( + EvaporasiViewMode.period), + ); + Navigator.of(context).pop(); + }, + child: const Text( + "Kembali", + style: TextStyle(color: Colors.grey), + ), + ), + Text( + "Pilih Tanggal", + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: Colors.grey.shade700, + ), + ), + TextButton( + onPressed: () { + if (_selectedDay != null) { + context.read().add( + EvaporasiDateSelected(_selectedDay!), + ); + Navigator.of(context).pop(); + } + }, + child: const Text( + "OK", + style: TextStyle( + color: Colors.blue, + fontWeight: FontWeight.bold, + ), + ), + ), + ], + ), + ), + // Calendar + Expanded( + child: SingleChildScrollView( + child: TableCalendar( + firstDay: DateTime.utc(2020, 1, 1), + lastDay: DateTime.now(), + focusedDay: _focusedDay, + calendarFormat: _calendarFormat, + selectedDayPredicate: (day) { + return isSameDay(_selectedDay, day); + }, + onDaySelected: (selectedDay, focusedDay) { + setState(() { + _selectedDay = selectedDay; + _focusedDay = focusedDay; + }); + }, + onFormatChanged: (format) { + if (_calendarFormat != format) { + setState(() { + _calendarFormat = format; + }); + } + }, + onPageChanged: (focusedDay) { + setState(() { + _focusedDay = focusedDay; + }); + }, + calendarStyle: CalendarStyle( + // Default + defaultDecoration: const BoxDecoration( + color: Colors.transparent, + shape: BoxShape.circle, + ), + // Today + todayDecoration: BoxDecoration( + color: Colors.blue.shade100, + shape: BoxShape.circle, + ), + todayTextStyle: TextStyle( + color: Colors.blue.shade700, + fontWeight: FontWeight.bold, + ), + // Selected + selectedDecoration: const BoxDecoration( + color: Colors.blue, + shape: BoxShape.circle, + ), + selectedTextStyle: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.bold, + ), + // Outside days + outsideDaysVisible: false, + weekendTextStyle: TextStyle(color: Colors.grey.shade600), + ), + headerStyle: HeaderStyle( + formatButtonVisible: true, + titleCentered: true, + formatButtonShowsNext: false, + formatButtonDecoration: BoxDecoration( + border: Border.all(color: Colors.blue), + borderRadius: BorderRadius.circular(12), + ), + formatButtonTextStyle: const TextStyle( + color: Colors.blue, + ), + titleTextStyle: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: Colors.grey.shade700, + ), + leftChevronIcon: Icon( + Icons.chevron_left, + color: Colors.grey.shade600, + ), + rightChevronIcon: Icon( + Icons.chevron_right, + color: Colors.grey.shade600, + ), + ), + daysOfWeekStyle: DaysOfWeekStyle( + weekdayStyle: TextStyle( + color: Colors.grey.shade500, + fontWeight: FontWeight.w500, + fontSize: 12, + ), + weekendStyle: TextStyle( + color: Colors.grey.shade500, + fontWeight: FontWeight.w500, + fontSize: 12, + ), + ), + ), + ), + ), + // Selected date display + if (_selectedDay != null) + Container( + width: double.infinity, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.grey.shade100, + borderRadius: const BorderRadius.only( + bottomLeft: Radius.circular(20), + bottomRight: Radius.circular(20), + ), + ), + child: Text( + _formatDate(_selectedDay!), + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: Colors.grey.shade700, + ), + ), + ), + ], + ), + ); + } + + String _formatDate(DateTime date) { + final now = DateTime.now(); + final today = DateTime(now.year, now.month, now.day); + final yesterday = today.subtract(const Duration(days: 1)); + final selected = DateTime(date.year, date.month, date.day); + + if (selected == today) { + return "Hari Ini"; + } else if (selected == yesterday) { + return "Kemarin"; + } else { + return DateFormat('EEEE, d MMMM yyyy', 'id_ID').format(date); + } + } +} + +/// 🔹 Show Date Picker Dialog +void showEvaporasiDatePicker(BuildContext context) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (context) => Container( + margin: const EdgeInsets.all(16), + height: 450, + child: const EvaporasiDatePicker(), + ), + ); +} diff --git a/lib/screens/monitoring/evaporasi/views/widgets/evaporasi_date_search_bar.dart b/lib/screens/monitoring/evaporasi/views/widgets/evaporasi_date_search_bar.dart new file mode 100644 index 0000000..ea17f0e --- /dev/null +++ b/lib/screens/monitoring/evaporasi/views/widgets/evaporasi_date_search_bar.dart @@ -0,0 +1,54 @@ +import 'package:flutter/material.dart'; + +class EvaporasiDateSearchBar extends StatefulWidget { + final String initialQuery; + final ValueChanged onQueryChanged; + + const EvaporasiDateSearchBar({ + super.key, + required this.initialQuery, + required this.onQueryChanged, + }); + + @override + State createState() => _EvaporasiDateSearchBarState(); +} + +class _EvaporasiDateSearchBarState extends State { + 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(() {}); + }, + ); + } +} diff --git a/lib/screens/monitoring/evaporasi/views/widgets/evaporasi_period_selector.dart b/lib/screens/monitoring/evaporasi/views/widgets/evaporasi_period_selector.dart index dafe16f..2d5e17c 100644 --- a/lib/screens/monitoring/evaporasi/views/widgets/evaporasi_period_selector.dart +++ b/lib/screens/monitoring/evaporasi/views/widgets/evaporasi_period_selector.dart @@ -1,14 +1,17 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import '../../blocs/evaporasi_bloc.dart'; +import 'evaporasi_date_picker.dart'; class EvaporasiPeriodSelector extends StatelessWidget { const EvaporasiPeriodSelector({super.key}); @override Widget build(BuildContext context) { - final selectedPeriod = - context.select((EvaporasiBloc bloc) => bloc.state.selectedPeriod); + final state = context.select((EvaporasiBloc bloc) => bloc.state); + final selectedPeriod = state.selectedPeriod; + final viewMode = state.viewMode; + final selectedDate = state.selectedDate; return Container( padding: const EdgeInsets.all(4), @@ -19,16 +22,20 @@ class EvaporasiPeriodSelector extends StatelessWidget { child: Row( mainAxisSize: MainAxisSize.min, children: [ - _buildTab(context, "Hari Ini", selectedPeriod), - _buildTab(context, "Minggu Ini", selectedPeriod), - _buildTab(context, "Bulan Ini", selectedPeriod), + _buildTab(context, "Hari Ini", selectedPeriod, viewMode), + _buildTab(context, "Minggu Ini", selectedPeriod, viewMode), + _buildTab(context, "Bulan Ini", selectedPeriod, viewMode), + const SizedBox(width: 8), + // Date picker button + _buildDatePickerButton(context, viewMode, selectedDate), ], ), ); } - Widget _buildTab(BuildContext context, String label, String current) { - bool isActive = label == current; + Widget _buildTab(BuildContext context, String label, String current, EvaporasiViewMode viewMode) { + // If in customDate mode, show period tabs as inactive + bool isActive = viewMode == EvaporasiViewMode.period && label == current; return GestureDetector( onTap: () { @@ -51,5 +58,62 @@ class EvaporasiPeriodSelector extends StatelessWidget { ), ); } + +Widget _buildDatePickerButton(BuildContext context, EvaporasiViewMode viewMode, DateTime? selectedDate) { + final isActive = viewMode == EvaporasiViewMode.customDate; + + return GestureDetector( + onTap: () { + // Set mode ke customDate SEBELUM membuka date picker + final bloc = context.read(); + 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}"; + } } diff --git a/packages/monitoring_repository/lib/src/models/evaporasi.dart b/packages/monitoring_repository/lib/src/models/evaporasi.dart index 907cbc5..f048180 100644 --- a/packages/monitoring_repository/lib/src/models/evaporasi.dart +++ b/packages/monitoring_repository/lib/src/models/evaporasi.dart @@ -19,26 +19,115 @@ class Evaporasi { ); factory Evaporasi.fromJson(Map json) { - final now = DateTime.now(); - DateTime timestamp = now; + double toDoubleSafe(dynamic v) { + if (v is num) return v.toDouble(); + if (v is String) { + final s = v.trim(); + // dukung format seperti "12.3 cm" / "12,3" / "-" + final normalized = s.replaceAll(',', '.'); + final match = RegExp(r'[-+]?\d*\.?\d+').firstMatch(normalized); + if (match != null) { + return double.tryParse(match.group(0)!) ?? 0; + } + return double.tryParse(normalized) ?? 0; + } + return 0; + } - final waktuStr = json['waktu'] as String?; - if (waktuStr != 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; - timestamp = DateTime(now.year, now.month, now.day, jam, menit, detik); + final evaporasiVal = toDoubleSafe( + json['evaporasi_mm'] ?? + json['evaporasi'] ?? + json['evaporasiMm'] ?? + json['evaporation_mm'] ?? + json['evap_mm'] ?? + json['evaporasi_mm_'] ?? + json['evaporasi_mm '] ?? + json['evaporasi_value'] ?? + json['evaporasi_mm_k'] ?? + json['evaporasi_k'], + ); + + final suhuVal = toDoubleSafe( + json['suhu'] ?? + json['suhu_air'] ?? + json['suhuAir'] ?? + json['temp'] ?? + json['temperature'], + ); + + // Banyak kemungkinan penamaan field tinggi air. + // Pakai beberapa alias agar tidak default 0. + final tinggiVal = toDoubleSafe( + json['tinggi_air_cm'] ?? + json['tinggi_air'] ?? + json['tinggiAir'] ?? + json['tinggiAir_cm'] ?? + json['tinggi_air_cm_'] ?? + json['tinggi_air_cm '] ?? + json['water_level'] ?? + json['waterLevel'] ?? + json['tinggi_air_m'] ?? + json['tinggiAir_m'], + ); + + + // Default timestamp: fallback now (kalau field waktu tidak ada). + // Catatan: untuk Firebase seharusnya timestamp dikirim konsisten (ms atau ISO string). + DateTime timestamp = DateTime.now(); + + // dukung beberapa kemungkinan penamaan timestamp + final rawTimestamp = + json['timestamp'] ?? json['time'] ?? json['waktu'] ?? json['datetime']; + + if (rawTimestamp != null) { + + if (rawTimestamp is int) { + timestamp = DateTime.fromMillisecondsSinceEpoch(rawTimestamp); + } else if (rawTimestamp is double) { + timestamp = DateTime.fromMillisecondsSinceEpoch(rawTimestamp.toInt()); + } else if (rawTimestamp is String) { + final s = rawTimestamp.trim(); + // jika string berupa angka (ms/seconds) + final unixMs = int.tryParse(s); + if (unixMs != null) { + // heuristik: kalau nilainya terlalu kecil kemungkinan seconds + if (unixMs < 1000000000000) { + timestamp = DateTime.fromMillisecondsSinceEpoch(unixMs * 1000); + } else { + timestamp = DateTime.fromMillisecondsSinceEpoch(unixMs); + } + } else { + final parsed = DateTime.tryParse(s); + if (parsed != null) { + timestamp = parsed; + } + } + } + } else { + // legacy fallback dari field terpisah + final datetimeStr = json['datetime'] as String?; + if (datetimeStr != null) { + final parsed = DateTime.tryParse(datetimeStr); + if (parsed != null) timestamp = parsed; + } + + final waktuStr = json['waktu'] as String?; + if (waktuStr != null && datetimeStr == null) { + final parts = waktuStr.split(':'); + if (parts.length >= 2) { + final jam = int.tryParse(parts[0]) ?? 0; + final menit = int.tryParse(parts[1]) ?? 0; + final detik = parts.length >= 3 ? (int.tryParse(parts[2]) ?? 0) : 0; + final now = DateTime.now(); + timestamp = DateTime(now.year, now.month, now.day, jam, menit, detik); + } } } return Evaporasi( - evaporasi: (json['evaporasi'] ?? 0).toDouble(), - suhu: (json['suhu_air'] ?? 0).toDouble(), - tinggiAir: (json['tinggi_air'] ?? 0).toDouble(), - - /// 🔥 bikin timestamp dari jam & menit + evaporasi: evaporasiVal, + suhu: suhuVal, + tinggiAir: tinggiVal, timestamp: timestamp, ); } diff --git a/packages/user_repository/lib/src/firebase_user_repo.dart b/packages/user_repository/lib/src/firebase_user_repo.dart index f816dc5..a9d5126 100644 --- a/packages/user_repository/lib/src/firebase_user_repo.dart +++ b/packages/user_repository/lib/src/firebase_user_repo.dart @@ -10,11 +10,9 @@ class FirebaseUserRepo implements UserRepository { final FirebaseAuth _firebaseAuth; final userCollection = FirebaseFirestore.instance.collection('users'); - FirebaseUserRepo({ - FirebaseAuth? firebaseAuth, - }) : _firebaseAuth = firebaseAuth ?? FirebaseAuth.instance; + FirebaseUserRepo({FirebaseAuth? firebaseAuth}) + : _firebaseAuth = firebaseAuth ?? FirebaseAuth.instance; - /// == Melakukan Implement User == /// @override Stream get user { return _firebaseAuth.authStateChanges().flatMap((firebaseUser) async* { @@ -27,15 +25,8 @@ class FirebaseUserRepo implements UserRepository { yield MyUser.fromEntity( MyUserEntity.fromDocument(userData.data()!)); } else { - // User baru yang belum ada data di Firestore, tunggu sebentar - await Future.delayed(const Duration(milliseconds: 500)); - final retryData = await userCollection.doc(firebaseUser.uid).get(); - if (retryData.exists && retryData.data() != null) { - yield MyUser.fromEntity( - MyUserEntity.fromDocument(retryData.data()!)); - } else { - yield MyUser.empty; - } + // Data Firestore belum ada (seharusnya tidak terjadi dengan flow yang sudah diperbaiki) + yield MyUser.empty; } } catch (e) { log('Error fetching user data: $e'); @@ -45,7 +36,6 @@ class FirebaseUserRepo implements UserRepository { }); } - /// == Melakukan Implement Sign in == /// @override Future signIn(String email, String password) async { try { @@ -57,7 +47,6 @@ class FirebaseUserRepo implements UserRepository { } } - /// == Melakukan Implement Sign Up == /// @override Future signUp(MyUser myUser, String password) async { try { @@ -71,7 +60,6 @@ class FirebaseUserRepo implements UserRepository { } } - /// == Melakukan Implement Log Out == /// @override Future logOut() async { final GoogleSignIn googleSignIn = GoogleSignIn(); @@ -85,27 +73,26 @@ class FirebaseUserRepo implements UserRepository { await userCollection .doc(myUser.userId) .set(myUser.toEntity().toDocument()); - // Tunggu sebentar agar data tersimpan dengan baik sebelum stream update - await Future.delayed(const Duration(milliseconds: 500)); } catch (e) { log(e.toString()); rethrow; } } - /// == Sign in with Google == /// @override Future signInWithGoogle() async { try { + UserCredential userCredential; + if (kIsWeb) { final googleProvider = GoogleAuthProvider(); - await _firebaseAuth.signInWithPopup(googleProvider); + userCredential = await _firebaseAuth.signInWithPopup(googleProvider); } else { final GoogleSignIn googleSignIn = GoogleSignIn(); final googleUser = await googleSignIn.signIn(); if (googleUser == null) { - throw Exception("cancelled"); + throw Exception('cancelled'); } final GoogleSignInAuthentication googleAuth = @@ -115,7 +102,22 @@ class FirebaseUserRepo implements UserRepository { accessToken: googleAuth.accessToken, idToken: googleAuth.idToken, ); - await _firebaseAuth.signInWithCredential(credential); + userCredential = await _firebaseAuth.signInWithCredential(credential); + } + + // FIX: Cek apakah user sudah punya data Firestore + // Jika belum (user baru), buat dokumen sekarang juga + final firebaseUser = userCredential.user!; + final doc = await userCollection.doc(firebaseUser.uid).get(); + + if (!doc.exists) { + final newUser = MyUser( + userId: firebaseUser.uid, + email: firebaseUser.email ?? '', + name: firebaseUser.displayName ?? '', + hasActiveCart: false, + ); + await setUserData(newUser); } } catch (e) { log('Google sign-in error: $e'); diff --git a/packages/user_repository/lib/src/models/user.dart b/packages/user_repository/lib/src/models/user.dart index 745028f..e867c7a 100644 --- a/packages/user_repository/lib/src/models/user.dart +++ b/packages/user_repository/lib/src/models/user.dart @@ -1,4 +1,3 @@ - import '../entities/entities.dart'; class MyUser { @@ -7,12 +6,11 @@ class MyUser { String name; bool hasActiveCart; - MyUser ({ + MyUser({ required this.userId, required this.email, required this.name, required this.hasActiveCart, - }); static final empty = MyUser( @@ -40,9 +38,19 @@ class MyUser { ); } + @override + bool operator ==(Object other) => + identical(this, other) || + other is MyUser && + runtimeType == other.runtimeType && + userId == other.userId && + email == other.email; + + @override + int get hashCode => Object.hash(userId, email); + @override String toString() { return 'MyUser: $userId, email: $email, name: $name, hasActiveCart: $hasActiveCart}'; } - -} \ No newline at end of file +} diff --git a/pubspec.lock b/pubspec.lock index 4867820..ae5f51c 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -695,6 +695,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.27.7" + simple_gesture_detector: + dependency: transitive + description: + name: simple_gesture_detector + sha256: ba2cd5af24ff20a0b8d609cec3f40e5b0744d2a71804a2616ae086b9c19d19a3 + url: "https://pub.dev" + source: hosted + version: "0.2.1" sky_engine: dependency: transitive description: flutter @@ -740,6 +748,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.1" + table_calendar: + dependency: "direct main" + description: + name: table_calendar + sha256: "0c0c6219878b363a2d5f40c7afb159d845f253d061dc3c822aa0d5fe0f721982" + url: "https://pub.dev" + source: hosted + version: "3.2.0" term_glyph: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index fff0541..91178c7 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -4,6 +4,7 @@ publish_to: 'none' version: 1.0.0+1 environment: sdk: ^3.1.0 + dependencies: app_settings: ^7.0.0 bloc: ^8.1.0 @@ -16,6 +17,7 @@ dependencies: firebase_core: ^4.3.0 firebase_database: ^12.1.3 fl_chart: ^1.1.1 + table_calendar: ^3.1.2 flutter: sdk: flutter flutter_bloc: ^8.1.0 @@ -34,6 +36,7 @@ dev_dependencies: flutter_lints: ^3.0.0 flutter_test: sdk: flutter + flutter: uses-material-design: true assets: diff --git a/test/widget_test.dart b/test/widget_test.dart index 7a974a8..a0f255e 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -5,12 +5,11 @@ // gestures. You can also use WidgetTester to find child widgets in the widget // tree, read text, and verify that the values of widget properties are correct. -import 'package:firebase_database/firebase_database.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:klimatologiot/app.dart'; import 'package:monitoring_repository/monitoring_repository.dart'; import 'package:user_repository/user_repository.dart'; -import '../lib/app.dart'; // import 'package:klimatologiot/main.dart'; @@ -49,31 +48,38 @@ class FakeUserRepository implements UserRepository { } class FakeMonitoringRepository implements MonitoringRepository { - get _db => null; - - // Satu fungsi untuk semua jenis sensor - // Kamu cukup masukkan "path" database-nya saja - @override - // Jika ingin mengambil data sekali saja (bukan stream) - Future getSensorSnapshot(String path) async { - return await _db.ref(path).get(); + Future getSensorSnapshot( + String path, + T Function(Map) mapper, + ) async { + return mapper({}); } @override - Stream getSensorStream(String path) { - // TODO: implement getSensorStream - throw UnimplementedError(); + Stream getSensorStream( + String path, + T Function(Map) mapper, + ) { + return const Stream.empty(); + } + + @override + Future> getSensorHistory( + String path, + T Function(Map) mapper, + ) async { + return []; } } void main() { testWidgets('App loads', (WidgetTester tester) async { final fakeRepo = FakeUserRepository(); - final fakeMonitoring = FakeUserRepository(); + final fakeMonitoring = FakeMonitoringRepository(); // Build our app and trigger a frame. await tester.pumpWidget( - MyApp(fakeRepo, fakeMonitoring as MonitoringRepository), + MyApp(fakeRepo, fakeMonitoring), ); await tester.pump();