Merge branch 'branch' of https://github.com/kleponijo/klimatologi into branch

This commit is contained in:
Mochamad ongki ramadani 2026-05-09 20:57:54 +07:00
commit 6cc1b7f575
15 changed files with 747 additions and 583 deletions

View File

@ -11,25 +11,23 @@ class MyAppView extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return MaterialApp( return MaterialApp(
title: 'Klimatologi', title: 'Klimatologi',
debugShowCheckedModeBanner: false, debugShowCheckedModeBanner: false,
theme: ThemeData( theme: ThemeData(
colorScheme: ColorScheme.light( colorScheme: ColorScheme.light(
background: Colors.grey.shade100, surface: Colors.grey.shade100,
onBackground: Colors.black, onSurface: Colors.black,
primary: Colors.blue, primary: Colors.blue,
onPrimary: Colors.white onPrimary: Colors.white),
), ),
), home: BlocBuilder<AuthenticationBloc, AuthenticationState>(
home: BlocBuilder<AuthenticationBloc, AuthenticationState>( builder: ((context, state) {
builder: ((context, state){ if (state.status == AuthenticationStatus.authenticated) {
if(state.status == AuthenticationStatus.authenticated){ return HomeScreen();
return HomeScreen();
} else { } else {
return WelcomeScreen(); return WelcomeScreen();
} }
}), }),
) ));
);
} }
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -79,11 +79,16 @@ class EvaporasiBloc extends Bloc<EvaporasiEvent, EvaporasiState> {
); );
final lastValue = history.isNotEmpty ? history.last.evaporasi : 0.0; final lastValue = history.isNotEmpty ? history.last.evaporasi : 0.0;
final lastWaterLevel = history.isNotEmpty ? history.last.tinggiAir : 0.0;
final lastTemperature = history.isNotEmpty ? history.last.suhu : 0.0;
final (status, rain) = _computeWeatherStatus(lastValue); final (status, rain) = _computeWeatherStatus(lastValue);
_emitEvaporasiAlert(status, rain, lastValue); _emitEvaporasiAlert(status, rain, lastValue);
emit(state.copyWith( emit(state.copyWith(
history: history, history: history,
currentValue: lastValue,
waterLevel: lastWaterLevel,
temperature: lastTemperature,
dailyValues: dailyGraph, dailyValues: dailyGraph,
dailyTemperatures: dailyTempGraph, dailyTemperatures: dailyTempGraph,
weeklyValues: weeklyGraph, weeklyValues: weeklyGraph,
@ -299,4 +304,3 @@ class _EvaporasiRealtimeUpdated extends EvaporasiEvent {
@override @override
List<Object> get props => [data]; List<Object> get props => [data];
} }

View File

@ -0,0 +1,170 @@
import 'package:fl_chart/fl_chart.dart';
import 'package:flutter/material.dart';
class EvaporasiChartWidget extends StatelessWidget {
final List<double> dailyValues;
final List<double> dailyTemperatures;
final String period;
final List<String> chartLabels;
const EvaporasiChartWidget({
super.key,
required this.dailyValues,
required this.dailyTemperatures,
required this.period,
required this.chartLabels,
});
double _safeValue(double value) {
if (value.isNaN || value.isInfinite) return 0.0;
return value < 0 ? 0.0 : value;
}
double _maxY() {
final values = [
...dailyValues.map(_safeValue),
...dailyTemperatures.map(_safeValue),
];
if (values.isEmpty) return 10;
final maxValue = values.reduce((a, b) => a > b ? a : b);
return (maxValue * 1.4).clamp(10.0, 100.0);
}
List<FlSpot> _lineSpots(List<double> series) {
return series.asMap().entries.map((entry) {
return FlSpot(entry.key.toDouble(), _safeValue(entry.value));
}).toList();
}
String _getBottomLabel(int index) {
if (chartLabels.isEmpty || index < 0 || index >= chartLabels.length) {
return index.toString();
}
return chartLabels[index];
}
@override
Widget build(BuildContext context) {
final values = _lineSpots(dailyValues);
final temperatures = _lineSpots(dailyTemperatures);
if (values.isEmpty && temperatures.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'),
);
}
return Container(
height: 300,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(25),
boxShadow: [
BoxShadow(
color: Colors.black.withAlpha(13),
blurRadius: 10,
offset: const Offset(0, 5),
)
],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(25),
child: Padding(
padding: const EdgeInsets.all(12),
child: LineChart(
LineChartData(
minY: 0,
maxY: _maxY(),
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: const AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
topTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
rightTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
),
lineTouchData: LineTouchData(
handleBuiltInTouches: true,
touchTooltipData: LineTouchTooltipData(
getTooltipItems: (spots) {
return spots.map((spot) {
return LineTooltipItem(
spot.y.toStringAsFixed(1),
const TextStyle(color: Colors.white, fontSize: 12),
);
}).toList();
},
),
),
lineBarsData: [
if (values.isNotEmpty)
LineChartBarData(
spots: values,
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 (temperatures.isNotEmpty)
LineChartBarData(
spots: temperatures,
isCurved: true,
color: Colors.orange.shade700,
barWidth: 3,
dotData: FlDotData(show: false),
),
],
),
),
),
),
);
}
}

View File

@ -83,93 +83,95 @@ class _EvaporasiDatePickerState extends State<EvaporasiDatePicker> {
), ),
// Calendar // Calendar
Expanded( Expanded(
child: TableCalendar( child: SingleChildScrollView(
firstDay: DateTime.utc(2020, 1, 1), child: TableCalendar(
lastDay: DateTime.now(), firstDay: DateTime.utc(2020, 1, 1),
focusedDay: _focusedDay, lastDay: DateTime.now(),
calendarFormat: _calendarFormat, focusedDay: _focusedDay,
selectedDayPredicate: (day) { calendarFormat: _calendarFormat,
return isSameDay(_selectedDay, day); selectedDayPredicate: (day) {
}, return isSameDay(_selectedDay, day);
onDaySelected: (selectedDay, focusedDay) { },
setState(() { onDaySelected: (selectedDay, focusedDay) {
_selectedDay = selectedDay;
_focusedDay = focusedDay;
});
},
onFormatChanged: (format) {
if (_calendarFormat != format) {
setState(() { setState(() {
_calendarFormat = format; _selectedDay = selectedDay;
_focusedDay = focusedDay;
}); });
} },
}, onFormatChanged: (format) {
onPageChanged: (focusedDay) { if (_calendarFormat != format) {
_focusedDay = focusedDay; setState(() {
}, _calendarFormat = format;
calendarStyle: CalendarStyle( });
// Default }
defaultDecoration: BoxDecoration( },
color: Colors.transparent, onPageChanged: (focusedDay) {
shape: BoxShape.circle, _focusedDay = focusedDay;
},
calendarStyle: CalendarStyle(
// Default
defaultDecoration: BoxDecoration(
color: Colors.transparent,
shape: BoxShape.circle,
),
// Today
todayDecoration: BoxDecoration(
color: Colors.blue.shade100,
shape: BoxShape.circle,
),
todayTextStyle: TextStyle(
color: Colors.blue.shade700,
fontWeight: FontWeight.bold,
),
// Selected
selectedDecoration: BoxDecoration(
color: Colors.blue,
shape: BoxShape.circle,
),
selectedTextStyle: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
),
// Outside days
outsideDaysVisible: false,
weekendTextStyle: TextStyle(color: Colors.grey.shade600),
), ),
// Today headerStyle: HeaderStyle(
todayDecoration: BoxDecoration( formatButtonVisible: true,
color: Colors.blue.shade100, titleCentered: true,
shape: BoxShape.circle, 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,
),
), ),
todayTextStyle: TextStyle( daysOfWeekStyle: DaysOfWeekStyle(
color: Colors.blue.shade700, weekdayStyle: TextStyle(
fontWeight: FontWeight.bold, color: Colors.grey.shade500,
), fontWeight: FontWeight.w500,
// Selected fontSize: 12,
selectedDecoration: BoxDecoration( ),
color: Colors.blue, weekendStyle: TextStyle(
shape: BoxShape.circle, color: Colors.grey.shade500,
), fontWeight: FontWeight.w500,
selectedTextStyle: const TextStyle( fontSize: 12,
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,
), ),
), ),
), ),

View File

@ -1,7 +1,4 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:monitoring_repository/monitoring_repository.dart';
class EvaporasiDateSearchBar extends StatefulWidget { class EvaporasiDateSearchBar extends StatefulWidget {
final String initialQuery; final String initialQuery;
@ -13,14 +10,12 @@ class EvaporasiDateSearchBar extends StatefulWidget {
required this.onQueryChanged, required this.onQueryChanged,
}); });
@override @override
State<EvaporasiDateSearchBar> createState() => _EvaporasiDateSearchBarState(); State<EvaporasiDateSearchBar> createState() => _EvaporasiDateSearchBarState();
} }
class _EvaporasiDateSearchBarState extends State<EvaporasiDateSearchBar> { class _EvaporasiDateSearchBarState extends State<EvaporasiDateSearchBar> {
late final TextEditingController _controller; late final TextEditingController _controller;
DateTime? _selected;
@override @override
void initState() { void initState() {
@ -34,21 +29,9 @@ class _EvaporasiDateSearchBarState extends State<EvaporasiDateSearchBar> {
super.dispose(); super.dispose();
} }
bool _matches(Evaporasi item, String query) {
if (query.trim().isEmpty) return true;
final date = item.timestamp;
final ddMMyyyy = DateFormat('dd/MM/yyyy', 'id_ID').format(date);
final ddMMMYYYY = DateFormat('dd MMM yyyy', 'id_ID').format(date);
final q = query.trim().toLowerCase();
return ddMMyyyy.toLowerCase().contains(q) || ddMMMYYYY.toLowerCase().contains(q);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return TextField( return TextField(
controller: _controller, controller: _controller,
decoration: InputDecoration( decoration: InputDecoration(
hintText: 'Cari tanggal (dd/MM/yyyy)', hintText: 'Cari tanggal (dd/MM/yyyy)',
@ -69,4 +52,3 @@ class _EvaporasiDateSearchBarState extends State<EvaporasiDateSearchBar> {
); );
} }
} }

View File

@ -19,14 +19,21 @@ class Evaporasi {
); );
factory Evaporasi.fromJson(Map<dynamic, dynamic> json) { factory Evaporasi.fromJson(Map<dynamic, dynamic> json) {
final now = DateTime.now(); double toDoubleSafe(dynamic v) {
DateTime timestamp = now; if (v is num) return v.toDouble();
if (v is String) return double.tryParse(v) ?? 0;
return 0;
}
// Prioritas waktu sesuai firmware ESP32: final evaporasiVal = toDoubleSafe(
// 1) timestamp (Unix atau ISO string, kalau pernah dikirim) json['evaporasi_mm'] ?? json['evaporasi'],
// 2) datetime ("YYYY-MM-DD HH:MM:SS") );
// 3) waktu ("HH:MM:SS") fallback (pakai tanggal hari ini) final suhuVal = toDoubleSafe(json['suhu'] ?? json['suhu_air']);
final tinggiVal = toDoubleSafe(json['tinggi_air_cm'] ?? json['tinggi_air']);
DateTime timestamp = DateTime.now();
final rawTimestamp = json['timestamp']; final rawTimestamp = json['timestamp'];
if (rawTimestamp != null) { if (rawTimestamp != null) {
if (rawTimestamp is int) { if (rawTimestamp is int) {
timestamp = DateTime.fromMillisecondsSinceEpoch(rawTimestamp); timestamp = DateTime.fromMillisecondsSinceEpoch(rawTimestamp);
@ -58,31 +65,24 @@ class Evaporasi {
final jam = int.tryParse(parts[0]) ?? 0; final jam = int.tryParse(parts[0]) ?? 0;
final menit = int.tryParse(parts[1]) ?? 0; final menit = int.tryParse(parts[1]) ?? 0;
final detik = parts.length >= 3 ? (int.tryParse(parts[2]) ?? 0) : 0; final detik = parts.length >= 3 ? (int.tryParse(parts[2]) ?? 0) : 0;
timestamp = DateTime(now.year, now.month, now.day, jam, menit, detik); final now = DateTime.now();
timestamp = DateTime(
now.year,
now.month,
now.day,
jam,
menit,
detik,
);
} }
} }
} }
} }
// Field di DB (dari info Anda):
// - evaporasi_mm
// - suhu_air
// - tinggi_air_cm
// Namun tetap toleran jika key berubah.
final evaporasiVal = json['evaporasi_mm'] ?? json['evaporasi'] ?? 0;
final suhuVal = json['suhu_air'] ?? json['suhu'] ?? 0;
final tinggiVal = json['tinggi_air_cm'] ?? json['tinggi_air'] ?? 0;
double toDoubleSafe(dynamic v) {
if (v is num) return v.toDouble();
if (v is String) return double.tryParse(v) ?? 0;
return 0;
}
return Evaporasi( return Evaporasi(
evaporasi: toDoubleSafe(evaporasiVal), evaporasi: evaporasiVal,
suhu: toDoubleSafe(suhuVal), suhu: suhuVal,
tinggiAir: toDoubleSafe(tinggiVal), tinggiAir: tinggiVal,
timestamp: timestamp, timestamp: timestamp,
); );
} }

View File

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

View File

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

View File

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

View File

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